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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions e2e/tests/chat-inbox-delivery.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
'<cross-session-message from-name="poster (#testroom)">\n[#testroom] poster: @recipient hello from e2e\n</cross-session-message>',
'<cross-session-message from-name="poster (#testroom)">\n[#testroom] poster: @recipient hello from e2e\n' +
'reply via rt chat post <room> "..." or rt chat dm <handle> "..." (never SendMessage; this arrived through rt chat)\n</cross-session-message>',
);
}, 30_000);

Expand DownExpand Up@@ -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(
'<cross-session-message from-name="c (dm)">\n[dm] c: secret for a\n</cross-session-message>',
'<cross-session-message from-name="c (dm)">\n[dm] c: secret for a\n' +
'reply via rt chat post <room> "..." or rt chat dm <handle> "..." (never SendMessage; this arrived through rt chat)\n</cross-session-message>',
);
// b is not a participant of this DM: nothing about it ever reaches b's inbox.
await Bun.sleep(300);
Expand Down
15 changes: 12 additions & 3 deletions lib/__tests__/repo-index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. */
Expand DownExpand Up@@ -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 {
Expand Down
18 changes: 12 additions & 6 deletions lib/daemon/__tests__/chat-delivery.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,12 @@ async function settleWelcome(calls: unknown[]): Promise<void> {
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 <room> "..." or rt chat dm <handle> "..." (never SendMessage; this arrived through rt chat)';

beforeEach(() => {
drainNotifications();
setSetting("chat.humanHandle", "matt", "user");
Expand All@@ -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, '<cross-session-message from-name="a (#general)">\n[#general] a: @b hi\n</cross-session-message>'],
[sock, `<cross-session-message from-name="a (#general)">\n[#general] a: @b hi\n${STEER}\n</cross-session-message>`],
]);
expect(lastReadId(h.db, "general", "b")).toBe(posted.data.id);
});
Expand DownExpand Up@@ -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(
'<cross-session-message from-name="rt chat (2 messages)">\n[#general] a: one\n[#general] a: two\n</cross-session-message>',
`<cross-session-message from-name="rt chat (2 messages)">\n[#general] a: one\n[#general] a: two\n${STEER}\n</cross-session-message>`,
);
expect(lastReadId(h.db, "general", "b")).toBe(second.data.id);
});
Expand DownExpand Up@@ -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('<cross-session-message from-name="a (#general)">\n[#general] a: one\n</cross-session-message>');
expect(calls[1]![1]).toBe('<cross-session-message from-name="a (#general)">\n[#general] a: two\n</cross-session-message>');
expect(calls[0]![1]).toBe(`<cross-session-message from-name="a (#general)">\n[#general] a: one\n${STEER}\n</cross-session-message>`);
expect(calls[1]![1]).toBe(`<cross-session-message from-name="a (#general)">\n[#general] a: two\n${STEER}\n</cross-session-message>`);
expect(lastReadId(h.db, "general", "b")).toBe(second.data.id);
});

Expand DownExpand Up@@ -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(
'<cross-session-message from-name="rt chat (2 messages)">\n[#general] a: one\n[#general] a: two\n</cross-session-message>',
`<cross-session-message from-name="rt chat (2 messages)">\n[#general] a: one\n[#general] a: two\n${STEER}\n</cross-session-message>`,
);
expect(lastReadId(h.db, "general", "b")).toBe(second.data.id);
});
Expand DownExpand Up@@ -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, '<cross-session-message from-name="a (dm)">\n[dm] a: hi\n</cross-session-message>']]);
expect(calls).toEqual([[sock, `<cross-session-message from-name="a (dm)">\n[dm] a: hi\n${STEER}\n</cross-session-message>`]]);
});

test("the desk-notification path still fires on a mention, independent of inbox delivery", async () => {
Expand Down
6 changes: 4 additions & 2 deletions lib/daemon/handlers/chat.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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);
Expand DownExpand Up@@ -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 <room> "..."',
'Reply privately with: rt chat dm <handle> "..."',
"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.",
];
Expand Down
10 changes: 10 additions & 0 deletions lib/daemon/inbox.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,6 +86,16 @@ export function wrapCrossSession(label: string, body: string): string {
return `<cross-session-message from-name="${safe}">\n${body}\n</cross-session-message>`;
}

/**
* 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 <room> "..." or rt chat dm <handle> "..." (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 }>,
Expand Down
64 changes: 61 additions & 3 deletions lib/repo-index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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/<name>/` 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,
Expand DownExpand Up@@ -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",
Expand DownExpand Up@@ -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
}
Expand Down
11 changes: 9 additions & 2 deletions skills/rt-chat/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,8 +72,15 @@ cross-session message):
</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
Expand Down
Loading