diff --git a/e2e/tests/chat-inbox-delivery.test.ts b/e2e/tests/chat-inbox-delivery.test.ts
index 078e1718..0622f98a 100644
--- a/e2e/tests/chat-inbox-delivery.test.ts
+++ b/e2e/tests/chat-inbox-delivery.test.ts
@@ -234,7 +234,9 @@ describe("rt chat inbox delivery (e2e)", () => {
const frame = await waitForFrame(inbox.frames, (f) => frameContent(f).includes("hello from e2e"));
expect(frame.type).toBe("user");
- expect(frameContent(frame)).toBe("[#testroom] poster: @recipient hello from e2e");
+ expect(frameContent(frame)).toBe(
+ '\n[#testroom] poster: @recipient hello from e2e\n',
+ );
}, 30_000);
test("a DM lands only on its recipient's inbox, and stops once that recipient signs out", async () => {
@@ -269,7 +271,9 @@ describe("rt chat inbox delivery (e2e)", () => {
await dm(home, "a", "secret for a", "sess-c");
const frame = await waitForFrame(inboxA.frames, (f) => frameContent(f).includes("secret for a"));
- expect(frameContent(frame)).toBe("[dm] c: secret for a");
+ expect(frameContent(frame)).toBe(
+ '\n[dm] c: secret for a\n',
+ );
// b is not a participant of this DM: nothing about it ever reaches b's inbox.
await Bun.sleep(300);
expect(inboxB.frames.some((f) => frameContent(f).includes("secret for a"))).toBe(false);
diff --git a/lib/daemon/__tests__/chat-delivery.test.ts b/lib/daemon/__tests__/chat-delivery.test.ts
index ab2f612f..85849c9c 100644
--- a/lib/daemon/__tests__/chat-delivery.test.ts
+++ b/lib/daemon/__tests__/chat-delivery.test.ts
@@ -77,7 +77,9 @@ test("posting to a room delivers the body to a signed-in recipient's inbox and a
const posted = await h["chat:post"]({ room: "general", handle: "a", body: "@b hi" });
if (!posted.ok) throw new Error("unreachable");
await Bun.sleep(0);
- expect(calls).toEqual([[sock, "[#general] a: @b hi"]]);
+ expect(calls).toEqual([
+ [sock, '\n[#general] a: @b hi\n'],
+ ]);
expect(lastReadId(h.db, "general", "b")).toBe(posted.data.id);
});
@@ -276,7 +278,9 @@ test("a failed delivery batches with the next successful one, catching up the wh
if (!second.ok) throw new Error("unreachable");
await Bun.sleep(0);
expect(calls).toHaveLength(2);
- expect(calls[1]![1]).toBe("[#general] a: one\n[#general] a: two");
+ expect(calls[1]![1]).toBe(
+ '\n[#general] a: one\n[#general] a: two\n',
+ );
expect(lastReadId(h.db, "general", "b")).toBe(second.data.id);
});
@@ -320,8 +324,8 @@ test("concurrent posts to the same recipient serialize delivery so a held first
await Bun.sleep(0);
expect(calls).toHaveLength(2);
- expect(calls[0]![1]).toBe("[#general] a: one");
- expect(calls[1]![1]).toBe("[#general] a: two");
+ expect(calls[0]![1]).toBe('\n[#general] a: one\n');
+ expect(calls[1]![1]).toBe('\n[#general] a: two\n');
expect(lastReadId(h.db, "general", "b")).toBe(second.data.id);
});
@@ -364,7 +368,9 @@ test("a held first delivery that ultimately fails still lets the second carry bo
await Bun.sleep(0);
expect(calls).toHaveLength(2);
- expect(calls[1]![1]).toBe("[#general] a: one\n[#general] a: two");
+ expect(calls[1]![1]).toBe(
+ '\n[#general] a: one\n[#general] a: two\n',
+ );
expect(lastReadId(h.db, "general", "b")).toBe(second.data.id);
});
@@ -438,7 +444,7 @@ test("a dm post renders with the [dm] tag, not the room hash", async () => {
await settleWelcome(calls);
await h["chat:dm"]({ from: "a", to: "b", body: "hi" });
await Bun.sleep(0);
- expect(calls).toEqual([[sock, "[dm] a: hi"]]);
+ expect(calls).toEqual([[sock, '\n[dm] a: hi\n']]);
});
test("the desk-notification path still fires on a mention, independent of inbox delivery", async () => {
diff --git a/lib/daemon/__tests__/inbox.test.ts b/lib/daemon/__tests__/inbox.test.ts
index 7927a097..3dce5a81 100644
--- a/lib/daemon/__tests__/inbox.test.ts
+++ b/lib/daemon/__tests__/inbox.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
-import { deliverToInbox, renderDeliveries } from "../inbox.ts";
+import { deliverToInbox, deliveryLabel, renderDeliveries, wrapCrossSession } from "../inbox.ts";
test("renderDeliveries formats room and dm lines", () => {
expect(renderDeliveries([
@@ -11,6 +11,29 @@ test("renderDeliveries formats room and dm lines", () => {
])).toBe("[#general] max: hello\n[dm] eli: hi");
});
+test("wrapCrossSession produces the exact envelope Claude Code collapses on", () => {
+ expect(wrapCrossSession("max (#general)", "[#general] max: hello")).toBe(
+ '\n[#general] max: hello\n',
+ );
+});
+
+test("wrapCrossSession neutralizes attribute-breaking characters in the label", () => {
+ const wrapped = wrapCrossSession('x" bad="', "body");
+ expect(wrapped.startsWith("");
+});
+
+test("deliveryLabel names the sender for one message and counts a batch", () => {
+ expect(deliveryLabel([{ room: "general", dm: false, handle: "max" }])).toBe("max (#general)");
+ expect(deliveryLabel([{ room: "dm-1", dm: true, handle: "eli" }])).toBe("eli (dm)");
+ expect(deliveryLabel([
+ { room: "general", dm: false, handle: "max" },
+ { room: "general", dm: false, handle: "eli" },
+ { room: "general", dm: false, handle: "kai" },
+ ])).toBe("rt chat (3 messages)");
+});
+
test("deliverToInbox writes exactly one msgV:1 user frame line", async () => {
const path = join(mkdtempSync(join(tmpdir(), "inbox-")), "s.sock");
const lines: string[] = [];
diff --git a/lib/daemon/handlers/chat.ts b/lib/daemon/handlers/chat.ts
index 1989740b..0852d46d 100644
--- a/lib/daemon/handlers/chat.ts
+++ b/lib/daemon/handlers/chat.ts
@@ -45,7 +45,7 @@ import { herdrRequest } from "../../herdr/client.ts";
import { injectIntoPane, herdrError } from "../inject.ts";
import type { HerdrSnapshot } from "./pane.ts";
import { resolveInbox, inboxAlive } from "../../claude-registry.ts";
-import { deliverToInbox, renderDeliveries } from "../inbox.ts";
+import { deliverToInbox, deliveryLabel, renderDeliveries, wrapCrossSession } from "../inbox.ts";
import { repoForCwd, branchForCwd } from "../../repo-for-cwd.ts";
import { deriveRoomForCwdAsync } from "../../chat-room.ts";
import { runCapture } from "../../subprocess.ts";
@@ -152,7 +152,8 @@ async function deliverPost(
if (!binding || !inboxAlive(binding)) return;
const pending = pendingMessages(msg.room, recipient, msg.id, db);
if (pending.length === 0) return;
- const content = renderDeliveries(pending.map((m) => ({ room: msg.room, dm: msg.dm, handle: m.handle, body: m.body })));
+ const items = pending.map((m) => ({ room: msg.room, dm: msg.dm, handle: m.handle, body: m.body }));
+ const content = wrapCrossSession(deliveryLabel(items), renderDeliveries(items));
const result = await deps.deliver(binding.socketPath, content);
if (!result.ok) {
await reportUnreadBadge(herdr, presence.pane, pending.length);
@@ -575,7 +576,7 @@ export function createChatHandlers(opts: {
const peeked = peekUnread({ handle: data.handle, limit: WELCOME_CATCHUP_LIMIT }, db);
const catchup = peeked.map((r) => ({ room: r.room, lines: r.messages.map((m) => `${m.handle}: ${m.body}`) }));
const catchupCursors = peeked.map((r) => ({ room: r.room, upToId: r.messages[r.messages.length - 1]!.id }));
- const welcomeContent = renderWelcome(data.handle, rooms, catchup);
+ const welcomeContent = wrapCrossSession("rt chat", renderWelcome(data.handle, rooms, catchup));
const welcomeSessionId = sessionId;
queueMicrotask(() => {
deliverWelcome(db, deliveryChains, inboxDeps, welcomeSessionId, data.handle, welcomeContent, catchupCursors).catch((err) => {
diff --git a/lib/daemon/inbox.ts b/lib/daemon/inbox.ts
index e4e3618c..20e267e2 100644
--- a/lib/daemon/inbox.ts
+++ b/lib/daemon/inbox.ts
@@ -70,3 +70,29 @@ export function renderDeliveries(
.map((item) => `${item.dm ? "[dm]" : `[#${item.room}]`} ${item.handle}: ${item.body}`)
.join("\n");
}
+
+/**
+ * Claude Code's terminal renders an inbound peer message collapsed (one
+ * labeled row, body hidden until expanded) ONLY when the content opens with
+ * its `` envelope; bare text renders in full.
+ * `from-name` is the collapsed row's label. No `from` attribute: that is a
+ * SendMessage reply address, and rt recipients reply via `rt chat post/dm`
+ * (taught in the body), so advertising an unreachable address would misteach
+ * the reply path. The envelope changes presentation only -- the model always
+ * receives the full body.
+ */
+export function wrapCrossSession(label: string, body: string): string {
+ const safe = label.replace(/["<>]/g, "'");
+ return `\n${body}\n`;
+}
+
+/** The collapsed row's label: the sender for a single message, a count for a batched catch-up. */
+export function deliveryLabel(
+ items: Array<{ room: string; dm: boolean; handle: string }>,
+): string {
+ if (items.length === 1) {
+ const item = items[0]!;
+ return `${item.handle} (${item.dm ? "dm" : `#${item.room}`})`;
+ }
+ return `rt chat (${items.length} messages)`;
+}
diff --git a/skills/rt-chat/SKILL.md b/skills/rt-chat/SKILL.md
index 6acfa29a..267e5aea 100644
--- a/skills/rt-chat/SKILL.md
+++ b/skills/rt-chat/SKILL.md
@@ -62,12 +62,18 @@ to re-derive the reply contract from this doc afterward.
## How messages reach you
Delivery is automatic and push-based. A chat body arrives directly in your
-context as one line per message:
+context as one line per message, wrapped in your host's peer-message
+envelope (so your terminal shows it as a collapsed one-line row, like any
+cross-session message):
```
+
[#room] handle: body
+
```
+The envelope's `from-name` is a display label, not a reply address: reply
+with `rt chat post`/`rt chat dm` (below), never a session-messaging tool.
Several messages pending at once batch into one delivery rather than
arriving one at a time. There is nothing to arm, nothing to poll, and no
tool to keep running in the background: the daemon pushes into your inbox