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