diff --git a/changelog.d/106-bot-to-bot-chips.fixed.md b/changelog.d/106-bot-to-bot-chips.fixed.md
new file mode 100644
index 000000000..7a073ac6a
--- /dev/null
+++ b/changelog.d/106-bot-to-bot-chips.fixed.md
@@ -0,0 +1 @@
+Bot-to-Bot sends and own-chat followups drop `dsh-session:` chips, including a granted Bot of another owner, so a private chat cannot ride into the other Bot's Session. ([#106](https://github.com/ThinkFlowLab/FlyBot/issues/106))
diff --git a/changelog.d/111-taskid-stamp.fixed.md b/changelog.d/111-taskid-stamp.fixed.md
new file mode 100644
index 000000000..492362651
--- /dev/null
+++ b/changelog.d/111-taskid-stamp.fixed.md
@@ -0,0 +1 @@
+A working Bot's send stamps `meta.taskId` (the matching origin, or the latest working task) so a hold with two tasks in flight blocks the right one. ([#111](https://github.com/ThinkFlowLab/FlyBot/issues/111))
diff --git a/changelog.d/132-face-room-titles.fixed.md b/changelog.d/132-face-room-titles.fixed.md
new file mode 100644
index 000000000..b625f14c2
--- /dev/null
+++ b/changelog.d/132-face-room-titles.fixed.md
@@ -0,0 +1 @@
+Face-to-face room titles include the people and a distinguisher (`Face-to-face · Alice, Bob · 482913`, or `Oct 16 21:40` when there is no code), so two rooms with the same people are not both named Face-to-face room. ([#132](https://github.com/ThinkFlowLab/FlyBot/issues/132))
diff --git a/core/src/host/delivery.js b/core/src/host/delivery.js
index 3f489b598..c68b4d31e 100644
--- a/core/src/host/delivery.js
+++ b/core/src/host/delivery.js
@@ -38,6 +38,24 @@ export const cleanName = (name, fallback = 'a remote Bot') => {
}
export const fenceUntrusted = (name, text) => `\n${stripUntrusted(text ?? '')}\n`
+// Bot-authored followups are createUserMessage; DSH may resolve `dsh-session:` chips
+// there and attach the snapshot. Drop chips so a Bot cannot tunnel a private chat
+// into another Bot's own chat or a shared room (#106). Authorship stays the gate
+// on actor-authored send / room-post. Loop until stable so a nested marker cannot
+// reconstitute a valid chip after one pass.
+export function stripSessionChips(text) {
+ if (typeof text !== 'string' || !text.includes('dsh-session:')) return text
+ let out = text
+ for (let previous; previous !== out;) {
+ previous = out
+ out = out
+ .replace(/@\[((?:\\.|[^\]\\])*)\]\(dsh-session:[A-Za-z0-9_-]+\)/g, (_, label) => `@${String(label).replace(/\\([\\\]])/g, '$1')}`)
+ .replace(/dsh-session:[A-Za-z0-9_-]+/g, '')
+ }
+ if (out.includes('dsh-session:')) out = out.replace(/dsh-session:/g, '')
+ return out
+}
+
export function install(rt) {
const { ctx, state } = rt
@@ -114,8 +132,9 @@ export function install(rt) {
const agent = await resolveAgent(targetSessionId)
// A steered message cuts into a running turn at its next step; a follow-up
// queues for the next turn (see turns.js for how each is routed).
+ const body = fromId ? stripSessionChips(text) : text
const message = createUserMessage({
- content: [{ type: 'text', text: `${headerFor(role, fromName, room?.name, audience, note, deferred, { resumed, retried })}\n${untrusted ? fenceUntrusted(fromName, text) : text}` }],
+ content: [{ type: 'text', text: `${headerFor(role, fromName, room?.name, audience, note, deferred, { resumed, retried })}\n${untrusted ? fenceUntrusted(fromName, body) : body}` }],
source,
})
if (steer) agent.steer(message)
diff --git a/core/src/host/groups.js b/core/src/host/groups.js
index 4949882f8..1de2f6fbf 100644
--- a/core/src/host/groups.js
+++ b/core/src/host/groups.js
@@ -23,28 +23,12 @@ import { mentionSpans } from '../shared/mentions.js'
import { GROUP_REF, output } from './tools.js'
import { failureLine } from './turns.js'
import { currentParticipants } from './room-membership.js'
+import { stripSessionChips } from './delivery.js'
const ROOM_LOG_MAX = 60
const ROOM_FIRST_LOOK = 12
const ROOM_DELTA_MAX = 24
const ROOM_EARLIER_MAX = 8
-// Bot-authored group followups are createUserMessage; DSH may resolve `dsh-session:`
-// chips there and attach the snapshot to the last user post. Drop chips so a member
-// Bot cannot tunnel a private chat into the room (#106). Authorship stays the gate.
-function stripSessionChips(text) {
- if (typeof text !== 'string' || !text.includes('dsh-session:')) return text
- // Loop until stable: a nested marker like dsh-sessiondsh-session:x:ImQi must not
- // reconstitute a valid chip after one pass (#106 Codex review).
- let out = text
- for (let previous; previous !== out;) {
- previous = out
- out = out
- .replace(/@\[((?:\\.|[^\]\\])*)\]\(dsh-session:[A-Za-z0-9_-]+\)/g, (_, label) => `@${String(label).replace(/\\([\\\]])/g, '$1')}`)
- .replace(/dsh-session:[A-Za-z0-9_-]+/g, '')
- }
- if (out.includes('dsh-session:')) out = out.replace(/dsh-session:/g, '')
- return out
-}
const ROOM_EXTRA_TURNS = 4
const ROOM_ADMIN_EXTRA_TURNS = 4
const ROOM_TURNS_EACH = 2
@@ -615,6 +599,7 @@ export function install(rt) {
}
// A Bot's message into the group's own Session, which relays it as a new round.
+ // Chips are stripped here and again in deliver() for Bot-to-Bot own-chat followups (#106).
async function postToRoom(room, sender, text, extra = {}) {
const agent = await rt.resolveAgent(room.id)
agent.followup(createUserMessage({
diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js
index 5a27f9d13..4a13d8368 100644
--- a/core/src/host/hooks.js
+++ b/core/src/host/hooks.js
@@ -13,7 +13,7 @@ import { createHash, randomUUID } from 'node:crypto'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { mentionSpans } from '../shared/mentions.js'
import { isHomeLevelRemote } from '../shared/remote-label.js'
-import { cleanName } from './delivery.js'
+import { cleanName, stripSessionChips } from './delivery.js'
import { SOURCE_KIND } from './constants.js'
import { currentParticipants } from './room-membership.js'
import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -508,6 +508,12 @@ export function install(rt) {
// revoked since it was written is refused.
const ownerPolicy = msg => rt.reach?.(msg) ?? { action: 'allow' }
const run = async (msg) => {
+ const taskId = rt.taskIdForSend?.(msg)
+ if (taskId) msg.meta = { ...msg.meta, taskId }
+ if (typeof msg.text === 'string') {
+ msg.text = stripSessionChips(msg.text)
+ if (msg.deliverArgs) msg.deliverArgs = { ...msg.deliverArgs, text: msg.text }
+ }
const owner = ownerPolicy(msg)
if (owner.action === 'deny') return { action: 'deny', reason: owner.reason, by: owner.by }
const verdict = await evaluate(msg)
diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js
index 6f4b0b6ec..44f5fa3f9 100644
--- a/core/src/host/room-membership.js
+++ b/core/src/host/room-membership.js
@@ -34,6 +34,7 @@
// of a friend's room that invited their granted Bot.
import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js'
+import { formatFaceRoomTitle, isGenericFaceTitle } from '../shared/face-title.js'
import { encodeRoomShare, parseRoomShare } from '../shared/room-share.js'
import { cleanName, fenceUntrusted, isFencedUntrusted } from './delivery.js'
import { hasAnyGrant, mayShareRoom } from './reach.js'
@@ -1226,11 +1227,18 @@ export function install(rt) {
return room
}
+ const faceRoomFallbackTitle = (spec, room) => formatFaceRoomTitle({
+ name: spec.name || room?.name,
+ owners: (spec.members ?? room?.participants ?? []).map(member => member.ownerName || member.owner_name),
+ code: spec.joinCode || spec.code,
+ at: spec.startedAt || spec.expiresAt || room?.face?.startedAt || room?.createdAt,
+ })
+
rt.upsertFaceRoom = async function upsertFaceRoom(spec = {}) {
const serverId = String(spec.roomId || spec.faceRoomId || '')
if (!serverId) throw invalid('A face-to-face room needs a room id')
let room = Object.values(state.rooms || {}).find(item => item.face?.roomId === serverId)
- const title = spec.name || room?.name || 'Face-to-face room'
+ const title = faceRoomFallbackTitle(spec, room)
const at = Date.now()
if (!room) {
const id = spec.localId || (typeof rt.createSession === 'function' ? await rt.createSession(title) : `face-${serverId}`)
@@ -1260,7 +1268,7 @@ export function install(rt) {
eventCursor: Math.max(Number(room.face.eventCursor) || 0, Number(spec.eventCursor) || 0),
seenEvents: Array.isArray(room.face.seenEvents) ? room.face.seenEvents : [],
}
- if (spec.name) room.name = spec.name
+ if (spec.name || isGenericFaceTitle(room.name)) room.name = title
if (Array.isArray(spec.members)) rt.syncFaceParticipants(room, spec.members, spec.localBots)
await rt.save?.()
return room
diff --git a/core/src/host/tasks.js b/core/src/host/tasks.js
index 5b4e286bb..7a7af5723 100644
--- a/core/src/host/tasks.js
+++ b/core/src/host/tasks.js
@@ -87,6 +87,30 @@ export function install(rt) {
const save = () => rt.save()
// ---------------------------------------------------------------- host hooks
+ // Name the working task a send belongs to so a hold can block that one (#111).
+ // Prefer an already-stamped `meta.taskId`; else the Bot's only working task;
+ // else the one whose origin matches this send's session/room; else the most
+ // recently started. Clarification / workBotId still wait on #55.
+ rt.taskIdForSend = (msg) => {
+ const from = msg?.from
+ if (typeof from !== 'string') return undefined
+ const ofSender = task => task.ownerBotId === from || task.workBotId === from
+ const given = typeof msg?.meta?.taskId === 'string' ? msg.meta.taskId : undefined
+ if (given) {
+ const named = store()[given]
+ if (named && named.status === 'working' && ofSender(named)) return given
+ }
+ const working = Object.values(store()).filter(task => task.status === 'working' && ofSender(task))
+ if (working.length === 0) return undefined
+ if (working.length === 1) return working[0].id
+ const sessionId = typeof msg?.meta?.sessionId === 'string' ? msg.meta.sessionId : undefined
+ const roomId = typeof msg?.meta?.roomId === 'string' ? msg.meta.roomId : rt.roomOf?.(sessionId)?.id
+ const matched = working.filter(task =>
+ (roomId && task.origin?.roomId === roomId) || (sessionId && task.origin?.sessionId === sessionId))
+ if (matched.length === 1) return matched[0].id
+ return [...(matched.length ? matched : working)].sort((a, b) => b.updatedAt - a.updatedAt)[0].id
+ }
+
// A consequential send from the responsible Bot was held (#52 card): the task
// blocks on that approval until the owner decides. Link one task, not every
// working task of that Bot: `meta.taskId` names it, else the Bot's only working
diff --git a/core/src/shared/face-title.js b/core/src/shared/face-title.js
new file mode 100644
index 000000000..ce025983f
--- /dev/null
+++ b/core/src/shared/face-title.js
@@ -0,0 +1,48 @@
+// Face-to-face room titles (#132): people plus a distinguisher so two rooms with
+// the same owners are not both "Face-to-face room" / "Face-to-face · Alice, Bob".
+
+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
+
+export function isGenericFaceTitle(name) {
+ const n = String(name ?? '').trim()
+ return !n || n === 'Face-to-face room'
+}
+
+export function formatFaceWhen(value) {
+ const date = value instanceof Date ? value : new Date(value)
+ if (!Number.isFinite(date.getTime())) return ''
+ const hh = String(date.getHours()).padStart(2, '0')
+ const mm = String(date.getMinutes()).padStart(2, '0')
+ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${hh}:${mm}`
+}
+
+function uniqueOwners(owners) {
+ const seen = new Set()
+ const out = []
+ for (const raw of owners ?? []) {
+ const owner = String(raw ?? '').trim()
+ const key = owner.toLowerCase()
+ if (!owner || seen.has(key)) continue
+ seen.add(key)
+ out.push(owner)
+ }
+ return out.slice(0, 3)
+}
+
+export function joinCodeOf(code) {
+ return String(code ?? '').replace(/\D/g, '').slice(0, 6)
+}
+
+// `Face-to-face · Alice, Bob · 482913` when a code is known, else `… · Oct 16 21:40`.
+// A caller-chosen name that is not an auto title is kept.
+export function formatFaceRoomTitle({ name, owners, code, at } = {}) {
+ const named = String(name ?? '').trim()
+ if (named && !isGenericFaceTitle(named) && !named.startsWith('Face-to-face · ')) return named
+ const people = uniqueOwners(owners)
+ const join = joinCodeOf(code)
+ const when = formatFaceWhen(at) || formatFaceWhen(new Date())
+ const bits = ['Face-to-face']
+ if (people.length) bits.push(people.join(', '))
+ bits.push(join.length === 6 ? join : when)
+ return bits.join(' · ')
+}
diff --git a/core/test/context-sharing.test.js b/core/test/context-sharing.test.js
index 38654c730..39cc4b800 100644
--- a/core/test/context-sharing.test.js
+++ b/core/test/context-sharing.test.js
@@ -289,6 +289,27 @@ test('answer-question and schedule-create accept menu-encoded session chips the
if (ownSchedule.ok === false) assert.notEqual(ownSchedule.error.code, 'bot/forbidden', JSON.stringify(ownSchedule))
})
+test('a Bot-to-Bot send drops chat-reference chips in the other Bot\'s own chat, including cross-owner', async (t) => {
+ const { tool, agentOf, signIn } = await start(t)
+ const alice = await signIn('Alice')
+ await signIn('Bob', alice.sessionToken)
+ const sameOwner = await tool('message_bot', { to: 'Writer', message: 'see @[secret](dsh-session:a) please' }, 'a')
+ assert.match(String(sameOwner), /Delivered/, String(sameOwner))
+ const writer = agentOf('s').calls.map(message => (message.content ?? []).map(block => block.text ?? '').join('\n')).join('\n')
+ assert.ok(writer.includes('see @secret'), 'the rest of the Bot-to-Bot text still lands')
+ assert.ok(!writer.includes('dsh-session:'), 'chips do not enter the other Bot\'s own chat')
+ const cross = await tool('message_bot', { to: 'ChiefB', message: 'peek @[notes](dsh-session:a) and dsh-session:e' }, 's')
+ assert.match(String(cross), /Delivered/, String(cross))
+ const bob = agentOf('d').calls.map(message => (message.content ?? []).map(block => block.text ?? '').join('\n')).join('\n')
+ assert.ok(bob.includes('peek @notes'), 'cross-owner Bot-to-Bot keeps the label')
+ assert.ok(!bob.includes('dsh-session:'), 'a granted Bot cannot tunnel a private chip into another owner\'s chat')
+ const nested = await tool('message_bot', { to: 'Writer', message: 'nested @[secret](dsh-sessiondsh-session:x:ImEi) end' }, 'a')
+ assert.match(String(nested), /Delivered/, String(nested))
+ const again = agentOf('s').calls.map(message => (message.content ?? []).map(block => block.text ?? '').join('\n')).join('\n')
+ assert.ok(!again.includes('dsh-session:'), 'nested chips do not reconstitute in own-chat followups')
+ void alice
+})
+
test('a Bot post into a group drops chat-reference chips so they cannot tunnel a private chat', async (t) => {
const { tool, agentOf, signIn } = await start(t)
const alice = await signIn('Alice')
diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js
index c8f535bde..aee4eb765 100644
--- a/core/test/remote-room.test.js
+++ b/core/test/remote-room.test.js
@@ -268,6 +268,8 @@ test('face-to-face room hosts room-only remotes and skips friend fanout', async
})
assert.equal(isFaceRoom(room), true)
assert.equal(room.origin, 'face-to-face')
+ assert.notEqual(room.name, 'Face-to-face room')
+ assert.match(room.name, /^Face-to-face · Alice, Bob · /)
const live = room.participants.filter(p => p.leftSeq === undefined)
assert.equal(live.some(p => p.id === 'spec-a' && !p.remote), true)
const remote = live.find(p => p.handle === '@coder.bob.home')
diff --git a/core/test/tasks.test.js b/core/test/tasks.test.js
index 2471efe24..ace45e739 100644
--- a/core/test/tasks.test.js
+++ b/core/test/tasks.test.js
@@ -279,7 +279,7 @@ test('a held send blocks only the named working task, not every working task of
assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'working')
})
-test('an untagged hold does not block every working task when the Bot has several', async (t) => {
+test('an untagged hold stamps the latest working task so two tasks do not share one card', async (t) => {
const { fb, call, signIn, createTask } = await start(t)
const alice = await signIn('Alice')
const first = await createTask(alice.sessionToken, { botId: 'a', title: 'Note one' })
@@ -292,7 +292,27 @@ test('an untagged hold does not block every working task when the Bot has severa
const held = await fb.bot.send({ from: 'a', to: 's', text: 'untagged send', meta: { hold: true } })
assert.equal(held.action, 'hold', JSON.stringify(held))
assert.equal((await statusOf(call, alice.sessionToken, first.value.id)).status, 'working')
- assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'working')
+ assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'blocked')
+ const rejected = await call('flybot/decide', { id: held.approvalId, decision: 'reject' }, alice.sessionToken)
+ assert.equal(rejected.ok, true, JSON.stringify(rejected))
+ assert.equal((await statusOf(call, alice.sessionToken, first.value.id)).status, 'working')
+ assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'cancelled')
+})
+
+test('a send without taskId matches the working task whose origin is this session', async (t) => {
+ const { fb, call, signIn, createTask } = await start(t)
+ const alice = await signIn('Alice')
+ const first = await createTask(alice.sessionToken, { botId: 'a', title: 'Chat one', sessionId: 'a' })
+ const second = await createTask(alice.sessionToken, { botId: 'a', title: 'Room two', roomId: 'war' })
+ for (const id of [first.value.id, second.value.id]) {
+ await call('task-accept', { id }, alice.sessionToken)
+ await call('task-start', { id }, alice.sessionToken)
+ }
+ fb.bot.send.use({ name: 'gate', handle: async msg => (msg.meta?.hold ? { action: 'hold', reason: 'held by test' } : undefined) })
+ const held = await fb.bot.send({ from: 'a', to: 's', text: 'from the room', meta: { hold: true, roomId: 'war' } })
+ assert.equal(held.action, 'hold', JSON.stringify(held))
+ assert.equal((await statusOf(call, alice.sessionToken, first.value.id)).status, 'working')
+ assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'blocked')
})
test('a result-ready task is not pruned while it waits for acceptance', async (t) => {
diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md
index 7311258c0..fba33f852 100644
--- a/docs/subsystems/core-host.md
+++ b/docs/subsystems/core-host.md
@@ -50,11 +50,11 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t
- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list.
-- Context sharing (#53) is specified in [team](team.md), **Context sharing foundation**: the own-chat brief is share-gated (`share-brief` / `briefShared`), a memory review reads only its owner as the user (`isUserLine`) and labels another human as `peer` (not `bot`), and keeps cross-owner entries out of the team scope (`teamAllowedFrom`), `list_bots` lists only the caller's owner's Bots (a granted child: that child's `createdBy` subtree), Bots granted to the same owner, and Bots in the current room — another owner's folder and instructions stay private — and a `dsh-session:` chip in actor-authored `send` / `room-post` / `answer-question` / `schedule-create` text must resolve for the author (`sessionOwnedBy`; `schedule-create` authz keys off `body.target`). Bot-authored group posts drop chips so they cannot tunnel a private chat into the room. Host modules: `src/host/groups.js`, `src/host/memory.js`, `src/host/tools.js`, `src/host/channel.js`, `src/host/room-membership.js`, `src/host/authz.js`. Team mode stays disabled.
+- Context sharing (#53) is specified in [team](team.md), **Context sharing foundation**: the own-chat brief is share-gated (`share-brief` / `briefShared`), a memory review reads only its owner as the user (`isUserLine`) and labels another human as `peer` (not `bot`), and keeps cross-owner entries out of the team scope (`teamAllowedFrom`), `list_bots` lists only the caller's owner's Bots (a granted child: that child's `createdBy` subtree), Bots granted to the same owner, and Bots in the current room — another owner's folder and instructions stay private — and a `dsh-session:` chip in actor-authored `send` / `room-post` / `answer-question` / `schedule-create` text must resolve for the author (`sessionOwnedBy`; `schedule-create` authz keys off `body.target`). Bot-authored group posts and Bot-to-Bot / own-chat followups drop chips so they cannot tunnel a private chat into another Session. Host modules: `src/host/groups.js`, `src/host/memory.js`, `src/host/tools.js`, `src/host/channel.js`, `src/host/room-membership.js`, `src/host/authz.js`. Team mode stays disabled.
### Tasks
-A tracked request with a stable id (`task-`) and a small state machine, owned by `src/host/tasks.js` with `state.tasks` (additive; corrupt values reset, no version bump). States: `open → claimed → working → blocked ⇄ working → result-ready → accepted`, with `cancelled`/`failed` terminal and `accepted` reachable only from `result-ready` — a failed or cancelled task can never display as accepted (#54). Transitions after create are the requester's (server-derived `requesterId`, the signed-in actor or the single owner; `bot/forbidden` otherwise, `bot/stale` on invalid moves), recorded with per-status actor entries. Create does not require owning the Bot: a matching per-Bot grant, or both the requester and the Bot as current members of the named room, is enough; outgoing holds stay the owner's (#52 card). A working task's held send blocks **that** task (`meta.taskId`, or the Bot's only working task) with the approval id; the owner's decide resumes (approve) or cancels (reject) it; deleting the responsible Bot fails its open tasks. `result-ready` is protected from prune while it waits for acceptance. Endpoints `tasks` / `task-create` / `task-accept` / `task-start` / `task-return` / `task-accept-result` / `task-cancel` are an explicit authz allow-list and are record-checked in tasks.js. Records store pointers and a copied summary, never private transcripts. Clarification and `workBotId` wait on #55's exchange wiring. Team mode stays disabled.
+A tracked request with a stable id (`task-`) and a small state machine, owned by `src/host/tasks.js` with `state.tasks` (additive; corrupt values reset, no version bump). States: `open → claimed → working → blocked ⇄ working → result-ready → accepted`, with `cancelled`/`failed` terminal and `accepted` reachable only from `result-ready` — a failed or cancelled task can never display as accepted (#54). Transitions after create are the requester's (server-derived `requesterId`, the signed-in actor or the single owner; `bot/forbidden` otherwise, `bot/stale` on invalid moves), recorded with per-status actor entries. Create does not require owning the Bot: a matching per-Bot grant, or both the requester and the Bot as current members of the named room, is enough; outgoing holds stay the owner's (#52 card). A working task's held send blocks **that** task (`meta.taskId` stamped on the send, matching origin when the Bot has several working tasks) with the approval id; the owner's decide resumes (approve) or cancels (reject) it; deleting the responsible Bot fails its open tasks. `result-ready` is protected from prune while it waits for acceptance. Endpoints `tasks` / `task-create` / `task-accept` / `task-start` / `task-return` / `task-accept-result` / `task-cancel` are an explicit authz allow-list and are record-checked in tasks.js. Records store pointers and a copied summary, never private transcripts. Clarification and `workBotId` wait on #55's exchange wiring. Team mode stays disabled.
### Inbound shared rooms
diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md
index 5df90d5d7..94ea6f39d 100644
--- a/docs/subsystems/plugin-runtime.md
+++ b/docs/subsystems/plugin-runtime.md
@@ -52,7 +52,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots
8. A user's @ of a friend's Bot in a group gets a receipt line in that group (`hooks.js` `mentionReceipt`, relay role `mention`): where the reply lands, or why the send was denied, held, or failed. The receipt's role stays outside the room round trigger (`groups.js` `opens`), so it never opens a round; peer names go through `cleanName`. A human line that names a friend's Bot already in that room is **not** 1:1-relayed as the group's admin (Chief): the room message is the delivery, so Chief's missing grant does not block it (#143). A Chief-originated `bot.send` to an ungranted friend Bot is still deny. A remaining grant deny on a mention that is *not* in the room says to invite the Bot or add it to the group.
9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` / `postNotice` / `postRemote`). Slot `flybot.room.overview` is a block at the top of a group's overview: while joining, a tile grid (avatar above name + owner) with **Leave** and **Enter chat** on one row; once open, compact **Leave** and Auto-send — not Open/Start/Leave together. Slot `flybot.new-chat.action` adds a row to the sidebar **+** short action menu (next to Create group chat; `label` / `hint` go through the same `t()` map). Still not team mode.
10. Held sends are owner-bound (#52): a hold stamps server-derived provenance on the record — `requestId`, a sha256 `version` over the request content, `origin` (`via`/`hop`/audience) and the sender Bot's `ownerId`; message claims (`meta`, text) never confer authority. `flybot/decide` passes authz for any signed-in actor and hooks binds the decision to the sender Bot's owner's session; `release` revalidates and marks records `stale` (changed request, deleted sender/target Bot, changed owner — `deleteBot` invalidates eagerly; a demoted Chief's sender-side holds are invalidated eagerly on `setMain`, not inside `staleReason`). `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. The pending list is filtered per actor under member enforcement. The default approval card shows `senderName`/`targetName` and a delegated marker (`Approval.origin`).
-11. Task lifecycle (#54): a working task's held send calls `rt.taskHeld` (blocks **that** task: `meta.taskId`, or the Bot's only working task); the owner's `flybot/decide` calls `rt.taskDecided` (approve resumes, reject cancels); `deleteBot` of a local Bot calls `rt.taskBotDeleted` (open tasks fail). Incoming holds stay the owner's. The state machine lives in `tasks.js`; hooks only fire those three seams. Clarification and `workBotId` wait on #55.
+11. Task lifecycle (#54): `bot.send` stamps `meta.taskId` from the working task (`rt.taskIdForSend`); a held send then calls `rt.taskHeld` (blocks **that** task). The owner's `flybot/decide` calls `rt.taskDecided` (approve resumes, reject cancels); `deleteBot` of a local Bot calls `rt.taskBotDeleted` (open tasks fail). Incoming holds stay the owner's. The state machine lives in `tasks.js`; hooks only fire those three seams. Clarification and `workBotId` wait on #55.
### Chat-row pending badge
diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md
index 022c42ad9..45a805542 100644
--- a/docs/subsystems/social.md
+++ b/docs/subsystems/social.md
@@ -76,7 +76,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers
### Past rooms, unique titles, poll, and + (#129–#133)
-- Expired matching (`candidate` whose `expiresAt` has passed) and a peer leaving the last remote seat move the hosted room to **Past rooms** on the next poll (`rememberPastFace`, status `closed` / `left`). The local room stays so **Open** still works; **Leave** is not shown on those cards. Face-room names include the other owners, else the 6-digit code, else a clock time — not a pile of identical "Face-to-face room".
+- Expired matching (`candidate` whose `expiresAt` has passed) and a peer leaving the last remote seat move the hosted room to **Past rooms** on the next poll (`rememberPastFace`, status `closed` / `left`). The local room stays so **Open** still works; **Leave** is not shown on those cards. Face-room names are `Face-to-face · Alice, Bob · 482913` (people plus the 6-digit code, or `Oct 16 21:40` when there is no code) so two rooms with the same people stay distinct. The literal `Face-to-face room` is not a fallback.
- Inbox and face poll share one timer: `facePollMs` (default 4s) while any live face room exists, else `inboxPollMs` (default 15s). The open face overview keeps polling about every 3s after **Enter chat**, not only while matching. Friends' Bots still refreshes rooms about every 5s while that section is open.
- Sidebar **+** holds Face-to-face group, Add friend, Accept invite, and one-click Copy invite link. Friend requests is not a duplicate + row. Per-friend and face-room hold toggles both say **Auto-send** ("Don't ask each time."). Hold labels resolve the real Bot name (`Chief → the room`), never `bot_01…`.
diff --git a/docs/subsystems/team.md b/docs/subsystems/team.md
index e4d3425a0..63de50fe2 100644
--- a/docs/subsystems/team.md
+++ b/docs/subsystems/team.md
@@ -104,12 +104,12 @@ Private context crosses owners only through an explicit, owner-granted share; ro
- **The own-chat brief is share-gated.** A member's group Session starts with its owner's own-chat brief only when the room has a single owner, or that owner called `share-brief` for this room (the `briefShared` flag lives on the room participant, so leaving drops it; `rt.setBriefShare` in `core/src/host/room-membership.js` owner-checks, the endpoint only needs a signed-in actor). A group Session created after the share is taken back carries no brief. A granted remote Bot in the room counts as another owner (its handle stands for its home), so a friend's Bot's presence also suppresses the brief. A brief already delivered is not retroactively unseen — document that to owners.
- **Memory review is owner-aware.** In `core/src/host/memory.js`, "the user" of a group review is the member Bot's owner (`isUserLine`); another owner's human is a named `peer` (not `bot`) and does not get user-line retention. A review that reads a room with more than one owner cannot write the team scope (`teamAllowedFrom`) — entries land in the Bot's own memory. Entries already written are not retroactively unseen.
- **Roster disclosure narrows.** `list_bots` lists the caller's owner's Bots, Bots granted to that owner, and Bots in the current room — never an ungranted Bot of another owner. A granted child triggered by a grantee is scoped to its own `createdBy` subtree and does not dump the owner's roster with `workDir` / instructions. Another owner's folder and instructions stay private when that Bot is listed at all.
-- **Chat references stay owner-scoped.** A `dsh-session:` reference chip in actor-authored text that becomes a session user message (`send`, `room-post`, `answer-question`, `schedule-create`) must name a Session the author may read (`sessionOwnedBy`); the snapshot rides to every recipient, so authorship is the gate. `schedule-create` authz keys off `body.target` (the Bot or room the endpoint uses), not a caller-chosen `sessionId`. Bot-authored group posts (`postToRoom`) drop chips so a member Bot cannot tunnel a private chat into the room. DSH resolves chips on user-shaped followups in the group's own Session (`session-reference` / `attachReference`) and attaches the snapshot only to the last user post, not a Bot's post.
-- **Not done here:** bot→bot own-chat followups are not stripped (the snapshot stays in that Bot's chat; not a shared-room leak); the secrets surface stays `SHARED_BLOCKED` for >1 member; previously received messages cannot be retroactively unseen (stated, not engineered); no per-owner file-sandbox changes — a room `workDir` remains readable by its member Bots under DSH's own policy. **Follow-up (brief share):** `briefAllowed` still withholds the owner's own-chat brief in any multi-owner room, including face-to-face rooms where someone brings their own Bot; the intended model is a grant-tied share (a flag on `state.grants` or the room-scoped grant) rather than a participant `briefShared` flag lost on leave. `setBriefShare` already requires the Bot to be a current room participant. That redesign is out of this #54 PR.
+- **Chat references stay owner-scoped.** A `dsh-session:` reference chip in actor-authored text that becomes a session user message (`send`, `room-post`, `answer-question`, `schedule-create`) must name a Session the author may read (`sessionOwnedBy`); the snapshot rides to every recipient, so authorship is the gate. `schedule-create` authz keys off `body.target` (the Bot or room the endpoint uses), not a caller-chosen `sessionId`. Bot-authored group posts (`postToRoom`) and Bot-to-Bot / own-chat followups (`rt.deliver` and `bot.send`, including a granted Bot of another owner) drop chips so a member Bot cannot tunnel a private chat. DSH resolves chips on user-shaped followups (`session-reference` / `attachReference`) and attaches the snapshot only to the last user post, not a Bot's post.
+- **Not done here:** the secrets surface stays `SHARED_BLOCKED` for >1 member; previously received messages cannot be retroactively unseen (stated, not engineered); no per-owner file-sandbox changes — a room `workDir` remains readable by its member Bots under DSH's own policy. **Follow-up (brief share):** `briefAllowed` still withholds the owner's own-chat brief in any multi-owner room, including face-to-face rooms where someone brings their own Bot; the intended model is a grant-tied share (a flag on `state.grants` or the room-scoped grant) rather than a participant `briefShared` flag lost on leave. `setBriefShare` already requires the Bot to be a current room participant. That redesign is out of this #54 PR.
## Task lifecycle foundation (landed, #54)
-`state.tasks` (`core/src/host/tasks.js`): a request becomes a tracked task — `open → claimed → working → blocked ⇄ working → result-ready → accepted`, `cancelled`/`failed` terminal, `accepted` only from `result-ready`. Only the requester's session moves their task (`bot/forbidden` otherwise). Create does not require owning the Bot: a matching per-Bot grant, or both the requester and the Bot as current members of the named room, is enough — a granted child can take a handoff within its authority. Outgoing holds stay the owner's: a held send (#52) blocks **that** task (`meta.taskId`, or the Bot's only working task) until that owner decides — several working tasks without a name stay working. `result-ready` is protected from prune while it waits for acceptance. Task channel names are an explicit allow-list (`tasks` / `task-create` / `task-accept` / `task-start` / `task-return` / `task-accept-result` / `task-cancel`). Records carry per-status actor entries, pointers to the originating conversation, and a summary — never private transcripts. The in-room task view and audit trail are #57's; clarification (ask the requester) and `workBotId` (the specialist on the exchange) wait on #55's exchange wiring. Team mode stays disabled.
+`state.tasks` (`core/src/host/tasks.js`): a request becomes a tracked task — `open → claimed → working → blocked ⇄ working → result-ready → accepted`, `cancelled`/`failed` terminal, `accepted` only from `result-ready`. Only the requester's session moves their task (`bot/forbidden` otherwise). Create does not require owning the Bot: a matching per-Bot grant, or both the requester and the Bot as current members of the named room, is enough — a granted child can take a handoff within its authority. Outgoing holds stay the owner's: a held send (#52) blocks **that** task (`meta.taskId` stamped on the send from the working task — origin session/room when the Bot has several, else the latest — or an explicit id) until that owner decides. `result-ready` is protected from prune while it waits for acceptance. Task channel names are an explicit allow-list (`tasks` / `task-create` / `task-accept` / `task-start` / `task-return` / `task-accept-result` / `task-cancel`). Records carry per-status actor entries, pointers to the originating conversation, and a summary — never private transcripts. The in-room task view and audit trail are #57's; clarification (ask the requester) and `workBotId` (the specialist on the exchange) wait on #55's exchange wiring. Team mode stays disabled.
## Identity model
@@ -229,7 +229,7 @@ Owner-scoped access (#49) tests in `core/test/authz.test.js`: cross-owner denial
**Approval binding (#52)** has tests in `core/test/approval-binding.test.js`: the hold stamps server-derived provenance and a request-content hash while forged `meta` claims stay data; only the sender Bot's owner's session may decide (another member gets `bot/forbidden`, no session gets `bot/unauthorized`, a body `actorId` grants nothing); the pending list is per-actor; a request that changed after it was held goes `stale` and never dispatches; same-owner deciding survives a restart; deleting a sender or target Bot or replacing the Chief invalidates pending *sender-side* holds, while a hold *addressed to* the demoted Chief stays pending and decidable; an in-flight approve is invalidated if the Chief is replaced before dispatch; and a record whose sender Bot and owner are both gone stays in state with its attribution but in no member's list.
-**Context sharing (#53)** has tests in `core/test/context-sharing.test.js` (brief gate per room ownership, the owner-only `share-brief` round trip, the single-owner guard, `list_bots` narrowing to own / granted / current-room Bots and granted-child subtree scoping, reference-chip authorization on `send`/`room-post`/`answer-question`/`schedule-create`, and chip stripping on Bot group posts) and in `core/test/memory.test.js` (owner-as-user classification, the team-write gate out of a cross-owner room).
+**Context sharing (#53)** has tests in `core/test/context-sharing.test.js` (brief gate per room ownership, the owner-only `share-brief` round trip, the single-owner guard, `list_bots` narrowing to own / granted / current-room Bots and granted-child subtree scoping, reference-chip authorization on `send`/`room-post`/`answer-question`/`schedule-create`, and chip stripping on Bot group posts and Bot-to-Bot / own-chat followups) and in `core/test/memory.test.js` (owner-as-user classification, the team-write gate out of a cross-owner room).
**Task lifecycle (#54)** has tests in `core/test/tasks.test.js`: the happy path (open → claimed → working → result-ready → accepted, then stale), wrong-member accept/claim/cancel forbidden, accept-before-result and post-terminal moves stale, held-send block → owner approve resumes, owner reject cancels, a named hold blocks only that working task (an untagged hold does not cancel every sibling), `result-ready` survives prune, responsible-Bot deletion fails the task, restart persistence, corrupt `state.tasks` resets, and a grantee may create a task on a granted child but not on an ungranted sibling.
diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch
index d45f664d2..e55cc679f 100644
--- a/patches/ds-bot/0005-social-seams.patch
+++ b/patches/ds-bot/0005-social-seams.patch
@@ -523,7 +523,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js
diff --git a/src/host/delivery.js b/src/host/delivery.js
--- a/src/host/delivery.js
+++ b/src/host/delivery.js
-@@ -3,6 +3,41 @@ import { randomUUID } from 'node:crypto'
+@@ -3,6 +3,59 @@ import { randomUUID } from 'node:crypto'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SOURCE_KIND } from './constants.js'
@@ -561,6 +561,24 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js
+ return text || fallback
+}
+export const fenceUntrusted = (name, text) => `\n${stripUntrusted(text ?? '')}\n`
++
++// Bot-authored followups are createUserMessage; DSH may resolve `dsh-session:` chips
++// there and attach the snapshot. Drop chips so a Bot cannot tunnel a private chat
++// into another Bot's own chat or a shared room (#106). Authorship stays the gate
++// on actor-authored send / room-post. Loop until stable so a nested marker cannot
++// reconstitute a valid chip after one pass.
++export function stripSessionChips(text) {
++ if (typeof text !== 'string' || !text.includes('dsh-session:')) return text
++ let out = text
++ for (let previous; previous !== out;) {
++ previous = out
++ out = out
++ .replace(/@\[((?:\\.|[^\]\\])*)\]\(dsh-session:[A-Za-z0-9_-]+\)/g, (_, label) => `@${String(label).replace(/\\([\\\]])/g, '$1')}`)
++ .replace(/dsh-session:[A-Za-z0-9_-]+/g, '')
++ }
++ if (out.includes('dsh-session:')) out = out.replace(/dsh-session:/g, '')
++ return out
++}
+
export function install(rt) {
const { ctx, state } = rt
@@ -600,12 +618,14 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js
}
if (EXCHANGE_ROLES.has(role) && !deferred && !resumed && !retried && !resent) {
state.exchanges.push({ id: exchangeId, from: fromId, to: toId, text, role, time: Date.now(), ...(replyTo ? { replyTo } : {}), ...(audience === 'user' ? { audience } : {}) })
-@@ -73,7 +73,7 @@ export function install(rt) {
+@@ -72,8 +72,9 @@ export function install(rt) {
+ const agent = await resolveAgent(targetSessionId)
// A steered message cuts into a running turn at its next step; a follow-up
// queues for the next turn (see turns.js for how each is routed).
++ const body = fromId ? stripSessionChips(text) : text
const message = createUserMessage({
- content: [{ type: 'text', text: `${headerFor(role, fromName, room?.name, audience, note, deferred, { resumed, retried })}\n${text}` }],
-+ content: [{ type: 'text', text: `${headerFor(role, fromName, room?.name, audience, note, deferred, { resumed, retried })}\n${untrusted ? fenceUntrusted(fromName, text) : text}` }],
++ content: [{ type: 'text', text: `${headerFor(role, fromName, room?.name, audience, note, deferred, { resumed, retried })}\n${untrusted ? fenceUntrusted(fromName, body) : body}` }],
source,
})
if (steer) agent.steer(message)
diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch
index 6c7d568ff..8cd531460 100644
--- a/patches/ds-bot/0012-room-membership.patch
+++ b/patches/ds-bot/0012-room-membership.patch
@@ -320,36 +320,15 @@ diff --git a/src/client/sources.js b/src/client/sources.js
diff --git a/src/host/groups.js b/src/host/groups.js
--- a/src/host/groups.js
+++ b/src/host/groups.js
-@@ -22,11 +22,29 @@ import { escapeRegExp, splitRef, textOf } from './text.js'
+@@ -22,6 +22,8 @@ import { escapeRegExp, splitRef, textOf } from './text.js'
import { mentionSpans } from '../shared/mentions.js'
import { GROUP_REF, output } from './tools.js'
import { failureLine } from './turns.js'
+import { currentParticipants } from './room-membership.js'
++import { stripSessionChips } from './delivery.js'
const ROOM_LOG_MAX = 60
const ROOM_FIRST_LOOK = 12
- const ROOM_DELTA_MAX = 24
- const ROOM_EARLIER_MAX = 8
-+// Bot-authored group followups are createUserMessage; DSH may resolve `dsh-session:`
-+// chips there and attach the snapshot to the last user post. Drop chips so a member
-+// Bot cannot tunnel a private chat into the room (#106). Authorship stays the gate.
-+function stripSessionChips(text) {
-+ if (typeof text !== 'string' || !text.includes('dsh-session:')) return text
-+ // Loop until stable: a nested marker like dsh-sessiondsh-session:x:ImQi must not
-+ // reconstitute a valid chip after one pass (#106 Codex review).
-+ let out = text
-+ for (let previous; previous !== out;) {
-+ previous = out
-+ out = out
-+ .replace(/@\[((?:\\.|[^\]\\])*)\]\(dsh-session:[A-Za-z0-9_-]+\)/g, (_, label) => `@${String(label).replace(/\\([\\\]])/g, '$1')}`)
-+ .replace(/dsh-session:[A-Za-z0-9_-]+/g, '')
-+ }
-+ if (out.includes('dsh-session:')) out = out.replace(/dsh-session:/g, '')
-+ return out
-+}
- const ROOM_EXTRA_TURNS = 4
- const ROOM_ADMIN_EXTRA_TURNS = 4
- const ROOM_TURNS_EACH = 2
@@ -41,9 +41,12 @@ const ensureRoomLog = (room) => {
room.seen ??= {}
}
@@ -476,7 +455,11 @@ diff --git a/src/host/groups.js b/src/host/groups.js
return lines.length === 0 ? `"${room.name}" has no messages yet.` : `Latest in "${room.name}" (oldest first):\n${transcript(lines)}`
},
}))
-@@ -566,7 +566,7 @@ export function install(rt) {
+@@ -563,10 +563,11 @@ export function install(rt) {
+ }
+
+ // A Bot's message into the group's own Session, which relays it as a new round.
++ // Chips are stripped here and again in deliver() for Bot-to-Bot own-chat followups (#106).
async function postToRoom(room, sender, text, extra = {}) {
const agent = await rt.resolveAgent(room.id)
agent.followup(createUserMessage({
diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js
index 5a27f9d13..4a13d8368 100644
--- a/patches/ds-bot/files/src/host/hooks.js
+++ b/patches/ds-bot/files/src/host/hooks.js
@@ -13,7 +13,7 @@ import { createHash, randomUUID } from 'node:crypto'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { mentionSpans } from '../shared/mentions.js'
import { isHomeLevelRemote } from '../shared/remote-label.js'
-import { cleanName } from './delivery.js'
+import { cleanName, stripSessionChips } from './delivery.js'
import { SOURCE_KIND } from './constants.js'
import { currentParticipants } from './room-membership.js'
import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -508,6 +508,12 @@ export function install(rt) {
// revoked since it was written is refused.
const ownerPolicy = msg => rt.reach?.(msg) ?? { action: 'allow' }
const run = async (msg) => {
+ const taskId = rt.taskIdForSend?.(msg)
+ if (taskId) msg.meta = { ...msg.meta, taskId }
+ if (typeof msg.text === 'string') {
+ msg.text = stripSessionChips(msg.text)
+ if (msg.deliverArgs) msg.deliverArgs = { ...msg.deliverArgs, text: msg.text }
+ }
const owner = ownerPolicy(msg)
if (owner.action === 'deny') return { action: 'deny', reason: owner.reason, by: owner.by }
const verdict = await evaluate(msg)
diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js
index 6f4b0b6ec..44f5fa3f9 100644
--- a/patches/ds-bot/files/src/host/room-membership.js
+++ b/patches/ds-bot/files/src/host/room-membership.js
@@ -34,6 +34,7 @@
// of a friend's room that invited their granted Bot.
import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js'
+import { formatFaceRoomTitle, isGenericFaceTitle } from '../shared/face-title.js'
import { encodeRoomShare, parseRoomShare } from '../shared/room-share.js'
import { cleanName, fenceUntrusted, isFencedUntrusted } from './delivery.js'
import { hasAnyGrant, mayShareRoom } from './reach.js'
@@ -1226,11 +1227,18 @@ export function install(rt) {
return room
}
+ const faceRoomFallbackTitle = (spec, room) => formatFaceRoomTitle({
+ name: spec.name || room?.name,
+ owners: (spec.members ?? room?.participants ?? []).map(member => member.ownerName || member.owner_name),
+ code: spec.joinCode || spec.code,
+ at: spec.startedAt || spec.expiresAt || room?.face?.startedAt || room?.createdAt,
+ })
+
rt.upsertFaceRoom = async function upsertFaceRoom(spec = {}) {
const serverId = String(spec.roomId || spec.faceRoomId || '')
if (!serverId) throw invalid('A face-to-face room needs a room id')
let room = Object.values(state.rooms || {}).find(item => item.face?.roomId === serverId)
- const title = spec.name || room?.name || 'Face-to-face room'
+ const title = faceRoomFallbackTitle(spec, room)
const at = Date.now()
if (!room) {
const id = spec.localId || (typeof rt.createSession === 'function' ? await rt.createSession(title) : `face-${serverId}`)
@@ -1260,7 +1268,7 @@ export function install(rt) {
eventCursor: Math.max(Number(room.face.eventCursor) || 0, Number(spec.eventCursor) || 0),
seenEvents: Array.isArray(room.face.seenEvents) ? room.face.seenEvents : [],
}
- if (spec.name) room.name = spec.name
+ if (spec.name || isGenericFaceTitle(room.name)) room.name = title
if (Array.isArray(spec.members)) rt.syncFaceParticipants(room, spec.members, spec.localBots)
await rt.save?.()
return room
diff --git a/patches/ds-bot/files/src/host/tasks.js b/patches/ds-bot/files/src/host/tasks.js
index 5b4e286bb..7a7af5723 100644
--- a/patches/ds-bot/files/src/host/tasks.js
+++ b/patches/ds-bot/files/src/host/tasks.js
@@ -87,6 +87,30 @@ export function install(rt) {
const save = () => rt.save()
// ---------------------------------------------------------------- host hooks
+ // Name the working task a send belongs to so a hold can block that one (#111).
+ // Prefer an already-stamped `meta.taskId`; else the Bot's only working task;
+ // else the one whose origin matches this send's session/room; else the most
+ // recently started. Clarification / workBotId still wait on #55.
+ rt.taskIdForSend = (msg) => {
+ const from = msg?.from
+ if (typeof from !== 'string') return undefined
+ const ofSender = task => task.ownerBotId === from || task.workBotId === from
+ const given = typeof msg?.meta?.taskId === 'string' ? msg.meta.taskId : undefined
+ if (given) {
+ const named = store()[given]
+ if (named && named.status === 'working' && ofSender(named)) return given
+ }
+ const working = Object.values(store()).filter(task => task.status === 'working' && ofSender(task))
+ if (working.length === 0) return undefined
+ if (working.length === 1) return working[0].id
+ const sessionId = typeof msg?.meta?.sessionId === 'string' ? msg.meta.sessionId : undefined
+ const roomId = typeof msg?.meta?.roomId === 'string' ? msg.meta.roomId : rt.roomOf?.(sessionId)?.id
+ const matched = working.filter(task =>
+ (roomId && task.origin?.roomId === roomId) || (sessionId && task.origin?.sessionId === sessionId))
+ if (matched.length === 1) return matched[0].id
+ return [...(matched.length ? matched : working)].sort((a, b) => b.updatedAt - a.updatedAt)[0].id
+ }
+
// A consequential send from the responsible Bot was held (#52 card): the task
// blocks on that approval until the owner decides. Link one task, not every
// working task of that Bot: `meta.taskId` names it, else the Bot's only working
diff --git a/patches/ds-bot/files/src/shared/face-title.js b/patches/ds-bot/files/src/shared/face-title.js
new file mode 100644
index 000000000..ce025983f
--- /dev/null
+++ b/patches/ds-bot/files/src/shared/face-title.js
@@ -0,0 +1,48 @@
+// Face-to-face room titles (#132): people plus a distinguisher so two rooms with
+// the same owners are not both "Face-to-face room" / "Face-to-face · Alice, Bob".
+
+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
+
+export function isGenericFaceTitle(name) {
+ const n = String(name ?? '').trim()
+ return !n || n === 'Face-to-face room'
+}
+
+export function formatFaceWhen(value) {
+ const date = value instanceof Date ? value : new Date(value)
+ if (!Number.isFinite(date.getTime())) return ''
+ const hh = String(date.getHours()).padStart(2, '0')
+ const mm = String(date.getMinutes()).padStart(2, '0')
+ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${hh}:${mm}`
+}
+
+function uniqueOwners(owners) {
+ const seen = new Set()
+ const out = []
+ for (const raw of owners ?? []) {
+ const owner = String(raw ?? '').trim()
+ const key = owner.toLowerCase()
+ if (!owner || seen.has(key)) continue
+ seen.add(key)
+ out.push(owner)
+ }
+ return out.slice(0, 3)
+}
+
+export function joinCodeOf(code) {
+ return String(code ?? '').replace(/\D/g, '').slice(0, 6)
+}
+
+// `Face-to-face · Alice, Bob · 482913` when a code is known, else `… · Oct 16 21:40`.
+// A caller-chosen name that is not an auto title is kept.
+export function formatFaceRoomTitle({ name, owners, code, at } = {}) {
+ const named = String(name ?? '').trim()
+ if (named && !isGenericFaceTitle(named) && !named.startsWith('Face-to-face · ')) return named
+ const people = uniqueOwners(owners)
+ const join = joinCodeOf(code)
+ const when = formatFaceWhen(at) || formatFaceWhen(new Date())
+ const bits = ['Face-to-face']
+ if (people.length) bits.push(people.join(', '))
+ bits.push(join.length === 6 ? join : when)
+ return bits.join(' · ')
+}
diff --git a/patches/ds-bot/files/test/context-sharing.test.js b/patches/ds-bot/files/test/context-sharing.test.js
index 38654c730..39cc4b800 100644
--- a/patches/ds-bot/files/test/context-sharing.test.js
+++ b/patches/ds-bot/files/test/context-sharing.test.js
@@ -289,6 +289,27 @@ test('answer-question and schedule-create accept menu-encoded session chips the
if (ownSchedule.ok === false) assert.notEqual(ownSchedule.error.code, 'bot/forbidden', JSON.stringify(ownSchedule))
})
+test('a Bot-to-Bot send drops chat-reference chips in the other Bot\'s own chat, including cross-owner', async (t) => {
+ const { tool, agentOf, signIn } = await start(t)
+ const alice = await signIn('Alice')
+ await signIn('Bob', alice.sessionToken)
+ const sameOwner = await tool('message_bot', { to: 'Writer', message: 'see @[secret](dsh-session:a) please' }, 'a')
+ assert.match(String(sameOwner), /Delivered/, String(sameOwner))
+ const writer = agentOf('s').calls.map(message => (message.content ?? []).map(block => block.text ?? '').join('\n')).join('\n')
+ assert.ok(writer.includes('see @secret'), 'the rest of the Bot-to-Bot text still lands')
+ assert.ok(!writer.includes('dsh-session:'), 'chips do not enter the other Bot\'s own chat')
+ const cross = await tool('message_bot', { to: 'ChiefB', message: 'peek @[notes](dsh-session:a) and dsh-session:e' }, 's')
+ assert.match(String(cross), /Delivered/, String(cross))
+ const bob = agentOf('d').calls.map(message => (message.content ?? []).map(block => block.text ?? '').join('\n')).join('\n')
+ assert.ok(bob.includes('peek @notes'), 'cross-owner Bot-to-Bot keeps the label')
+ assert.ok(!bob.includes('dsh-session:'), 'a granted Bot cannot tunnel a private chip into another owner\'s chat')
+ const nested = await tool('message_bot', { to: 'Writer', message: 'nested @[secret](dsh-sessiondsh-session:x:ImEi) end' }, 'a')
+ assert.match(String(nested), /Delivered/, String(nested))
+ const again = agentOf('s').calls.map(message => (message.content ?? []).map(block => block.text ?? '').join('\n')).join('\n')
+ assert.ok(!again.includes('dsh-session:'), 'nested chips do not reconstitute in own-chat followups')
+ void alice
+})
+
test('a Bot post into a group drops chat-reference chips so they cannot tunnel a private chat', async (t) => {
const { tool, agentOf, signIn } = await start(t)
const alice = await signIn('Alice')
diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js
index c8f535bde..aee4eb765 100644
--- a/patches/ds-bot/files/test/remote-room.test.js
+++ b/patches/ds-bot/files/test/remote-room.test.js
@@ -268,6 +268,8 @@ test('face-to-face room hosts room-only remotes and skips friend fanout', async
})
assert.equal(isFaceRoom(room), true)
assert.equal(room.origin, 'face-to-face')
+ assert.notEqual(room.name, 'Face-to-face room')
+ assert.match(room.name, /^Face-to-face · Alice, Bob · /)
const live = room.participants.filter(p => p.leftSeq === undefined)
assert.equal(live.some(p => p.id === 'spec-a' && !p.remote), true)
const remote = live.find(p => p.handle === '@coder.bob.home')
diff --git a/patches/ds-bot/files/test/tasks.test.js b/patches/ds-bot/files/test/tasks.test.js
index 2471efe24..ace45e739 100644
--- a/patches/ds-bot/files/test/tasks.test.js
+++ b/patches/ds-bot/files/test/tasks.test.js
@@ -279,7 +279,7 @@ test('a held send blocks only the named working task, not every working task of
assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'working')
})
-test('an untagged hold does not block every working task when the Bot has several', async (t) => {
+test('an untagged hold stamps the latest working task so two tasks do not share one card', async (t) => {
const { fb, call, signIn, createTask } = await start(t)
const alice = await signIn('Alice')
const first = await createTask(alice.sessionToken, { botId: 'a', title: 'Note one' })
@@ -292,7 +292,27 @@ test('an untagged hold does not block every working task when the Bot has severa
const held = await fb.bot.send({ from: 'a', to: 's', text: 'untagged send', meta: { hold: true } })
assert.equal(held.action, 'hold', JSON.stringify(held))
assert.equal((await statusOf(call, alice.sessionToken, first.value.id)).status, 'working')
- assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'working')
+ assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'blocked')
+ const rejected = await call('flybot/decide', { id: held.approvalId, decision: 'reject' }, alice.sessionToken)
+ assert.equal(rejected.ok, true, JSON.stringify(rejected))
+ assert.equal((await statusOf(call, alice.sessionToken, first.value.id)).status, 'working')
+ assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'cancelled')
+})
+
+test('a send without taskId matches the working task whose origin is this session', async (t) => {
+ const { fb, call, signIn, createTask } = await start(t)
+ const alice = await signIn('Alice')
+ const first = await createTask(alice.sessionToken, { botId: 'a', title: 'Chat one', sessionId: 'a' })
+ const second = await createTask(alice.sessionToken, { botId: 'a', title: 'Room two', roomId: 'war' })
+ for (const id of [first.value.id, second.value.id]) {
+ await call('task-accept', { id }, alice.sessionToken)
+ await call('task-start', { id }, alice.sessionToken)
+ }
+ fb.bot.send.use({ name: 'gate', handle: async msg => (msg.meta?.hold ? { action: 'hold', reason: 'held by test' } : undefined) })
+ const held = await fb.bot.send({ from: 'a', to: 's', text: 'from the room', meta: { hold: true, roomId: 'war' } })
+ assert.equal(held.action, 'hold', JSON.stringify(held))
+ assert.equal((await statusOf(call, alice.sessionToken, first.value.id)).status, 'working')
+ assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'blocked')
})
test('a result-ready task is not pruned while it waits for acceptance', async (t) => {
diff --git a/plugins/social/client.js b/plugins/social/client.js
index 7c4632db1..6008ef6a8 100644
--- a/plugins/social/client.js
+++ b/plugins/social/client.js
@@ -598,6 +598,30 @@ window.__ModuleLoader__.load({
}
return 'open'
}
+ function shownFaceTitle(room) {
+ const named = String(room?.name || '').trim()
+ if (named && named !== 'Face-to-face room') return named
+ const owners = []
+ const seen = new Set()
+ for (const member of room?.members ?? []) {
+ const owner = String(member.ownerName || member.owner_name || '').trim()
+ const key = owner.toLowerCase()
+ if (!owner || seen.has(key)) continue
+ seen.add(key)
+ owners.push(owner)
+ }
+ const code = String(room?.joinCode || room?.code || '').replace(/\D/g, '').slice(0, 6)
+ const bits = ['Face-to-face']
+ if (owners.length) bits.push(owners.slice(0, 3).join(', '))
+ if (code.length === 6) bits.push(code)
+ else {
+ const at = Date.parse(room?.startedAt || room?.expiresAt || '')
+ const when = Number.isFinite(at) ? new Date(at) : new Date()
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
+ bits.push(`${months[when.getMonth()]} ${when.getDate()} ${String(when.getHours()).padStart(2, '0')}:${String(when.getMinutes()).padStart(2, '0')}`)
+ }
+ return bits.join(' · ')
+ }
function FaceRoomCard({ room, actions, onChange, roster, remoteChats, past } = {}) {
const [busy, setBusy] = useState('')
const [note, setNote] = useState(null)
@@ -627,7 +651,7 @@ window.__ModuleLoader__.load({
shown.map(m => h('span', { key: m.handle || m.id, 'data-flybot': 'face-card-member', 'data-handle': m.handle },
h(FaceAvatar, { bot: look(m), size: 20 }))),
extra ? h('span', { className: 'fs-stack-more' }, `+${extra}`) : null),
- h('span', { className: 'fs-name' }, room.name || 'Face-to-face room'),
+ h('span', { className: 'fs-name' }, shownFaceTitle(room)),
h('span', { className: 'fs-chip', 'data-flybot': 'face-status' }, faceChip(room)),
h('span', { className: 'fs-face-actions', onClick: event => event.stopPropagation() },
past
diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js
index c647829ab..9b19dafbe 100644
--- a/plugins/social/src/face-rooms.js
+++ b/plugins/social/src/face-rooms.js
@@ -11,6 +11,7 @@
import { socialFetch } from './mcp-client.js'
import { lookFromServer } from './look.js'
+import { formatFaceRoomTitle } from '../../../core/src/shared/face-title.js'
export const FACE_JOIN_CODE = /^\d{6}$/
export const FACE_MAX_MEMBERS = 8
@@ -221,25 +222,18 @@ export function humanFaceWho(line, fallback = 'Someone') {
}
export function faceRoomTitle(snapshot = {}, { code, room } = {}) {
- const named = String(snapshot.name || room?.name || '').trim()
- if (named && named !== 'Face-to-face room') return named
const owners = []
- const seen = new Set()
for (const member of snapshot.members ?? room?.participants ?? []) {
const owner = cleanFaceName(member.ownerName || member.owner_name, '')
- const key = owner.toLowerCase()
- if (!owner || seen.has(key)) continue
- seen.add(key)
- owners.push(owner)
+ if (owner) owners.push(owner)
}
- if (owners.length) return `Face-to-face · ${owners.slice(0, 3).join(', ')}`
- const join = String(code || snapshot.joinCode || '').replace(/\D/g, '').slice(0, 6)
- if (join.length === 6) return `Face-to-face · ${join}`
- const at = Date.parse(snapshot.startedAt || snapshot.expiresAt || '')
- const when = Number.isFinite(at) ? new Date(at) : new Date()
- const hh = String(when.getHours()).padStart(2, '0')
- const mm = String(when.getMinutes()).padStart(2, '0')
- return `Face-to-face · ${hh}:${mm}`
+ const at = snapshot.startedAt || snapshot.expiresAt || room?.face?.startedAt || room?.face?.expiresAt || room?.createdAt
+ return formatFaceRoomTitle({
+ name: snapshot.name || room?.name,
+ owners,
+ code: code || snapshot.joinCode,
+ at,
+ })
}
export function parseFaceHuman(text) {
diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js
index 19f755be8..444a50d39 100644
--- a/plugins/social/src/social.js
+++ b/plugins/social/src/social.js
@@ -946,7 +946,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
await flybot.grants.set(botId, [...current, {
id: `room:${room.id}`,
grantee: faceRoomId ? `room:${faceRoomId}` : `room:${room.id}`,
- name: name || room.name || 'Face-to-face room',
+ name: name || faceRoomTitle({}, { room }),
roomId: String(room.id),
}])
}
@@ -2026,7 +2026,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
if (known.has(serverId)) continue
const hosted = flybot.rooms.ofFace?.(serverId)
rememberPastFace(viewHostedFace(hosted, { faceRoomId: serverId, localRoomId: meta?.localRoomId, status: 'closed' })
- || { faceRoomId: serverId, roomId: meta?.localRoomId, name: 'Face-to-face room', status: 'closed', members: [] })
+ || { faceRoomId: serverId, roomId: meta?.localRoomId, name: faceRoomTitle({}, { code: meta?.joinCode, room: hosted }), status: 'closed', members: [] })
if (meta?.localRoomId) await flybot.rooms.dropFace?.(meta.localRoomId).catch(() => {})
delete settings.faceRooms[serverId]
dropRoomRemotes(serverId)
@@ -2084,7 +2084,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
const meta = settings.faceRooms[id]
const hosted = flybot.rooms.ofFace?.(id)
rememberPastFace(viewHostedFace(hosted, { faceRoomId: id, localRoomId: meta?.localRoomId, status: 'left' })
- || { faceRoomId: id, roomId: meta?.localRoomId, name: 'Face-to-face room', status: 'left', members: [] })
+ || { faceRoomId: id, roomId: meta?.localRoomId, name: faceRoomTitle({}, { code: meta?.joinCode, room: hosted }), status: 'left', members: [] })
const handles = (Array.isArray(bots) && bots.length ? bots : asBotsOf(meta)).map(norm).filter(Boolean)
let result
try {
diff --git a/plugins/social/test/face-rooms.test.js b/plugins/social/test/face-rooms.test.js
index 79456fb4c..abf578cc8 100644
--- a/plugins/social/test/face-rooms.test.js
+++ b/plugins/social/test/face-rooms.test.js
@@ -123,8 +123,15 @@ test('owner-typed face-room lines encode as a human envelope', () => {
const unnamed = faceOutbound({ authorKind: 'human', who: 'User', text: 'hi', authorId: 'owner-1' }, { locals, asBots: ['@chief.boxa.home'], who: 'Alice' })
assert.deepEqual(parseFaceHuman(unnamed.text), { who: 'Alice', text: 'hi' })
assert.equal(humanFaceWho({ who: 'User' }, 'Alice'), 'Alice')
- assert.equal(faceRoomTitle({ members: [{ owner_name: 'BoxA' }, { ownerName: 'BoxB' }] }), 'Face-to-face · BoxA, BoxB')
+ assert.equal(faceRoomTitle({ members: [{ owner_name: 'BoxA' }, { ownerName: 'BoxB' }] }, { code: '482913' }), 'Face-to-face · BoxA, BoxB · 482913')
assert.equal(faceRoomTitle({}, { code: '123456' }), 'Face-to-face · 123456')
+ assert.equal(faceRoomTitle({ name: 'Kitchen' }), 'Kitchen')
+ assert.notEqual(
+ faceRoomTitle({ members: [{ owner_name: 'Alice' }], startedAt: '2026-10-16T13:40:00.000Z' }),
+ faceRoomTitle({ members: [{ owner_name: 'Alice' }], startedAt: '2026-10-16T14:05:00.000Z' }),
+ )
+ assert.doesNotMatch(faceRoomTitle({ members: [{ owner_name: 'Alice' }], startedAt: '2026-10-16T13:40:00.000Z' }), /^Face-to-face room$/)
+ assert.match(faceRoomTitle({ members: [{ owner_name: 'Alice' }], startedAt: '2026-10-16T13:40:00.000Z' }), /^Face-to-face · Alice · /)
const rendered = renderFaceEvent({
event_id: 'fev_h', event_cursor: 9, type: 'room.message',
from: { handle: '@chief.boxa.home', display_name: 'Chief', owner_name: 'BoxA' },
diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js
index b4f4b3325..46fe09b7b 100644
--- a/plugins/social/test/social.test.js
+++ b/plugins/social/test/social.test.js
@@ -1829,6 +1829,22 @@ test('Face-to-face room card members use host avatars, not a shared letter badge
assert.equal(tree.filter(n => n.props.className === 'fs-avatar' && n.props['data-flybot'] === undefined).length, 0)
})
+test('Face-to-face room card titles include people and a distinguisher, not Face-to-face room', () => {
+ const { walk, pluginOf } = loadSocialClient(async () => ({}))
+ const tree = walk(pluginOf([]).FaceRoomCard({
+ room: {
+ name: 'Face-to-face room', status: 'open', joinCode: '482913',
+ members: [
+ { handle: '@a.me.home', displayName: 'Chief', ownerName: 'Alice' },
+ { handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob' },
+ ],
+ },
+ }))
+ const title = tree.find(n => n.props.className === 'fs-name')
+ assert.equal(String(title?.children?.join?.('') ?? ''), 'Face-to-face · Alice, Bob · 482913')
+ assert.equal(tree.some(n => String(n.children?.join?.('') ?? n.children) === 'Face-to-face room'), false)
+})
+
test('Face-to-face room card is one compact row with a status chip and no invalid-number line', () => {
const { walk, pluginOf } = loadSocialClient(async () => ({}))
const plugin = pluginOf([])