diff --git a/e2e/tests/chat-inbox-delivery.test.ts b/e2e/tests/chat-inbox-delivery.test.ts
index 0622f98a..d558d724 100644
--- a/e2e/tests/chat-inbox-delivery.test.ts
+++ b/e2e/tests/chat-inbox-delivery.test.ts
@@ -235,7 +235,8 @@ 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(
- '\n[#testroom] poster: @recipient hello from e2e\n',
+ '\n[#testroom] poster: @recipient hello from e2e\n' +
+ 'reply via rt chat post "..." or rt chat dm "..." (never SendMessage; this arrived through rt chat)\n',
);
}, 30_000);
@@ -272,7 +273,8 @@ describe("rt chat inbox delivery (e2e)", () => {
const frame = await waitForFrame(inboxA.frames, (f) => frameContent(f).includes("secret for a"));
expect(frameContent(frame)).toBe(
- '\n[dm] c: secret for a\n',
+ '\n[dm] c: secret for a\n' +
+ 'reply via rt chat post "..." or rt chat dm "..." (never SendMessage; this arrived through rt chat)\n',
);
// b is not a participant of this DM: nothing about it ever reaches b's inbox.
await Bun.sleep(300);
diff --git a/lib/__tests__/repo-index.test.ts b/lib/__tests__/repo-index.test.ts
index 67ea4947..5c622a20 100644
--- a/lib/__tests__/repo-index.test.ts
+++ b/lib/__tests__/repo-index.test.ts
@@ -65,6 +65,14 @@ describe("repo-index — rt.repoRoots (RT-49)", () => {
return dir;
}
+ /** A marker repo whose `.git/HEAD` names a branch, so the fs-read branch
+ * label (getKnownRepos' single-worktree fast path) resolves without a spawn. */
+ function headedMarkerRepo(parent: string, name: string): string {
+ const dir = markerRepo(parent, name);
+ writeFileSync(join(dir, ".git", "HEAD"), "ref: refs/heads/main\n");
+ return dir;
+ }
+
/** A real, minimal git repo — used for "already indexed" fixtures so
* `git worktree list --porcelain` (the pre-existing, untouched call in
* getKnownRepos) resolves cleanly instead of falling into its catch. */
@@ -398,18 +406,19 @@ describe("repo-index — rt.repoRoots (RT-49)", () => {
}
});
- test("under the cap: branch hints populated, one spawn per candidate", () => {
+ test("under the cap: branch hints populated from HEAD, no git spawns", () => {
const { callsLog, restore } = fakeGit();
try {
const root = mkdtempSync(join(tmpdir(), "rt-cap-under-root-"));
- for (let i = 0; i < 3; i++) markerRepo(root, `repo-${i}`);
+ for (let i = 0; i < 3; i++) headedMarkerRepo(root, `repo-${i}`);
setRepoRoots([root]);
const repos = getKnownRepos();
const unregistered = repos.filter((r) => r.registered === false);
expect(unregistered.length).toBe(3);
expect(unregistered.every((r) => r.worktrees[0]?.branch === "main")).toBe(true);
- expect(callCount(callsLog)).toBe(3);
+ // Single-worktree branch labels are read from .git/HEAD, not spawned.
+ expect(callCount(callsLog)).toBe(0);
rmSync(root, { recursive: true, force: true });
} finally {
diff --git a/lib/daemon/__tests__/chat-delivery.test.ts b/lib/daemon/__tests__/chat-delivery.test.ts
index 85849c9c..f5997ffc 100644
--- a/lib/daemon/__tests__/chat-delivery.test.ts
+++ b/lib/daemon/__tests__/chat-delivery.test.ts
@@ -57,6 +57,12 @@ async function settleWelcome(calls: unknown[]): Promise {
calls.length = 0;
}
+// Kept as a literal (not imported from inbox.ts) so an accidental change to
+// the shipped steer line fails these assertions instead of vanishing into a
+// tautology.
+const STEER =
+ 'reply via rt chat post "..." or rt chat dm "..." (never SendMessage; this arrived through rt chat)';
+
beforeEach(() => {
drainNotifications();
setSetting("chat.humanHandle", "matt", "user");
@@ -78,7 +84,7 @@ test("posting to a room delivers the body to a signed-in recipient's inbox and a
if (!posted.ok) throw new Error("unreachable");
await Bun.sleep(0);
expect(calls).toEqual([
- [sock, '\n[#general] a: @b hi\n'],
+ [sock, `\n[#general] a: @b hi\n${STEER}\n`],
]);
expect(lastReadId(h.db, "general", "b")).toBe(posted.data.id);
});
@@ -279,7 +285,7 @@ test("a failed delivery batches with the next successful one, catching up the wh
await Bun.sleep(0);
expect(calls).toHaveLength(2);
expect(calls[1]![1]).toBe(
- '\n[#general] a: one\n[#general] a: two\n',
+ `\n[#general] a: one\n[#general] a: two\n${STEER}\n`,
);
expect(lastReadId(h.db, "general", "b")).toBe(second.data.id);
});
@@ -324,8 +330,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('\n[#general] a: one\n');
- expect(calls[1]![1]).toBe('\n[#general] a: two\n');
+ expect(calls[0]![1]).toBe(`\n[#general] a: one\n${STEER}\n`);
+ expect(calls[1]![1]).toBe(`\n[#general] a: two\n${STEER}\n`);
expect(lastReadId(h.db, "general", "b")).toBe(second.data.id);
});
@@ -369,7 +375,7 @@ test("a held first delivery that ultimately fails still lets the second carry bo
expect(calls).toHaveLength(2);
expect(calls[1]![1]).toBe(
- '\n[#general] a: one\n[#general] a: two\n',
+ `\n[#general] a: one\n[#general] a: two\n${STEER}\n`,
);
expect(lastReadId(h.db, "general", "b")).toBe(second.data.id);
});
@@ -444,7 +450,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, '\n[dm] a: hi\n']]);
+ expect(calls).toEqual([[sock, `\n[dm] a: hi\n${STEER}\n`]]);
});
test("the desk-notification path still fires on a mention, independent of inbox delivery", async () => {
diff --git a/lib/daemon/handlers/chat.ts b/lib/daemon/handlers/chat.ts
index 0852d46d..5d7b67f1 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, deliveryLabel, renderDeliveries, wrapCrossSession } from "../inbox.ts";
+import { deliverToInbox, deliveryLabel, renderDeliveries, REPLY_STEER, wrapCrossSession } from "../inbox.ts";
import { repoForCwd, branchForCwd } from "../../repo-for-cwd.ts";
import { deriveRoomForCwdAsync } from "../../chat-room.ts";
import { runCapture } from "../../subprocess.ts";
@@ -153,7 +153,7 @@ async function deliverPost(
const pending = pendingMessages(msg.room, recipient, msg.id, db);
if (pending.length === 0) return;
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 content = wrapCrossSession(deliveryLabel(items), `${renderDeliveries(items)}\n${REPLY_STEER}`);
const result = await deps.deliver(binding.socketPath, content);
if (!result.ok) {
await reportUnreadBadge(herdr, presence.pane, pending.length);
@@ -261,11 +261,13 @@ function deliverWelcome(
*/
export function renderWelcome(handle: string, rooms: string[], catchup: Array<{ room: string; lines: string[] }>): string {
const lines: string[] = [
+ "[rt chat] This frame is for THIS session, from the rt daemon (not another agent).",
`You're signed in to rt chat as ${handle}.`,
rooms.length ? `Rooms: ${rooms.map((r) => `#${r}`).join(", ")}` : "Rooms: none yet.",
"Messages will arrive in your context automatically; you never need to poll or arm anything.",
'Reply in a room with: rt chat post "..."',
'Reply privately with: rt chat dm "..."',
+ "Chat replies go through rt chat only, never SendMessage, even though deliveries arrive framed as coming from another session.",
"rt chat read shows a room's history.",
"See the rt:chat skill for the full etiquette.",
];
diff --git a/lib/daemon/inbox.ts b/lib/daemon/inbox.ts
index 20e267e2..2d1b1562 100644
--- a/lib/daemon/inbox.ts
+++ b/lib/daemon/inbox.ts
@@ -86,6 +86,16 @@ export function wrapCrossSession(label: string, body: string): string {
return `\n${body}\n`;
}
+/**
+ * Appended inside every wrapped message delivery. The host frames envelope
+ * content as "Another Claude session sent a message" and steers replies
+ * toward its own session-messaging tool, so the actual reply channel must
+ * be restated at the moment the reflex fires -- one line per delivery,
+ * never per message.
+ */
+export const REPLY_STEER =
+ 'reply via rt chat post "..." or rt chat dm "..." (never SendMessage; this arrived through rt chat)';
+
/** 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 }>,
diff --git a/lib/repo-index.ts b/lib/repo-index.ts
index e5ddf614..6873c858 100644
--- a/lib/repo-index.ts
+++ b/lib/repo-index.ts
@@ -21,7 +21,7 @@
*/
import { execSync } from "child_process";
-import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, type Dirent } from "fs";
+import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, type Dirent } from "fs";
import { homedir } from "os";
import { basename, dirname, join, resolve as resolvePath } from "path";
import { repoDataDir, rtDir } from "./rt-paths.ts";
@@ -137,8 +137,52 @@ export function loadRepoIndex(): RepoIndex {
return Object.keys(imported).length > 0 ? imported : existing;
}
+/**
+ * Branch name from a git dir's HEAD file. "" for a detached HEAD (HEAD holds a
+ * raw SHA, not a ref) or an unreadable/absent HEAD — matching what a `branch`
+ * line's absence in `git worktree list --porcelain` yields.
+ */
+function headBranch(gitDir: string): string {
+ try {
+ const m = readFileSync(join(gitDir, "HEAD"), "utf8").trim().match(/^ref: refs\/heads\/(.+)$/);
+ return m ? m[1] : "";
+ } catch {
+ return "";
+ }
+}
+
+/**
+ * Fast path for the overwhelmingly common single-worktree repo, avoiding a
+ * `git worktree list` subprocess per repo (the picker's dominant startup cost).
+ * Git creates `.git/worktrees//` for every LINKED worktree and for none
+ * of the main one, so a `.git` that is a real directory with an empty (or
+ * absent) `.git/worktrees/` has exactly one worktree, rooted at `dir` — git's
+ * own spelling of it, since `dir` is already `git rev-parse --show-toplevel`.
+ * Returns null for any other shape (`.git` a file = a linked worktree or
+ * submodule; a non-empty `.git/worktrees/`; a bare repo with no `.git`), and
+ * the caller falls back to the authoritative `git worktree list`.
+ */
+function singleWorktree(dir: string): { path: string; branch: string; isBare: false } | null {
+ const dotgit = join(dir, ".git");
+ let isDir = false;
+ try {
+ isDir = statSync(dotgit).isDirectory();
+ } catch {
+ return null;
+ }
+ if (!isDir) return null;
+ try {
+ if (readdirSync(join(dotgit, "worktrees")).length > 0) return null;
+ } catch { /* absent worktrees dir == single worktree */ }
+ return { path: dir, branch: headBranch(dotgit), isBare: false };
+}
+
/** The repo's MAIN worktree path as git reports it, degrading to `repoRoot`. */
function observedMainPath(repoRoot: string): string {
+ // A single-worktree repo's main worktree IS repoRoot (both are git's
+ // `--show-toplevel`), so the git spawn only earns its cost when linked
+ // worktrees exist and repoRoot might be one of them rather than the main.
+ if (singleWorktree(repoRoot)) return repoRoot;
try {
const listed = execSync("git worktree list --porcelain", {
cwd: repoRoot,
@@ -818,7 +862,13 @@ export function getKnownRepos(opts?: { includeMissing?: boolean }): KnownRepo[]
for (const { repoName, path: mainPath } of keep) {
const worktrees: KnownRepo["worktrees"] = [];
- try {
+ // Single-worktree repos (the vast majority) skip the git subprocess and
+ // synthesize the one worktree from disk; only repos with linked worktrees
+ // pay for the authoritative porcelain parse below.
+ const single = singleWorktree(mainPath);
+ if (single) {
+ worktrees.push(single);
+ } else try {
const output = execSync("git worktree list --porcelain", {
cwd: mainPath,
encoding: "utf8",
@@ -1005,13 +1055,21 @@ function candidateDataDirName(name: string, composite: boolean): string {
* performs (spec: branch-label cap).
*/
function branchOf(repoPath: string): string {
+ const dotgit = join(repoPath, ".git");
+ // A plain repo (.git is a directory) reads its branch straight from HEAD, no
+ // subprocess. A linked worktree (.git is a file) has no local HEAD ref file
+ // to parse, so it keeps the authoritative git spawn.
+ try {
+ if (statSync(dotgit).isDirectory()) return headBranch(dotgit);
+ } catch { /* fall through to the git spawn */ }
try {
- return execSync("git rev-parse --abbrev-ref HEAD", {
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: repoPath,
encoding: "utf8",
stdio: "pipe",
env: process.env,
}).trim();
+ return branch === "HEAD" ? "" : branch; // "HEAD" is git's detached-HEAD sentinel
} catch {
return ""; // detached HEAD or other edge case — leave blank
}
diff --git a/skills/rt-chat/SKILL.md b/skills/rt-chat/SKILL.md
index 267e5aea..f931e796 100644
--- a/skills/rt-chat/SKILL.md
+++ b/skills/rt-chat/SKILL.md
@@ -72,8 +72,15 @@ cross-session message):
```
-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.
+Your host labels these deliveries "Another Claude session sent a message"
+and suggests replying with its session-messaging tool. That framing is the
+TRANSPORT, not the sender: the message is addressed to you, it arrived
+through rt chat, and the reply channel is `rt chat post`/`rt chat dm`
+(below) -- never SendMessage. The envelope's `from-name` is a display
+label, not a reply address. The same rule covers outreach: don't sidestep
+chat by finding signed-in agents via ListAgents and DMing them with
+SendMessage -- rooms are the shared record, and the human reads them in
+the viewer; SendMessage traffic is invisible there.
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