diff --git a/CLAUDE.md b/CLAUDE.md index 3c63b9154..351ead190 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,6 +143,21 @@ something that does nothing. ## Footguns +### `bun run test` does not run e2e, and CI does + +`test` is `bun test lib commands packages scripts`; the e2e suite is a separate +script (`test:e2e`, and `test:all` for both) because it needs +`--preload ./e2e/setup.ts`. So a green local `bun run test` is not the same +gate CI applies, and the difference is invisible in the output. + +It matters most for anything asserted verbatim end to end: the chat delivery +frame, a CLI's `--json` envelope, a usage string. Those have exact-string +assertions in `e2e/tests/` that no unit suite covers, so a deliberate format +change reads as fully green locally and fails in CI. Run `bun run test:all`, +or at least the one e2e file covering the surface, before calling a change +verified. Claiming verification from a suite that never exercised the changed +contract is the actual defect here, not the red CI. + ### Module registry When adding a new command module referenced by `cli.ts` (any file with a `module:` entry in the command tree), you **must** also register it in `lib/module-registry.ts`. `bun build --compile` cannot resolve dynamic `import()` with runtime-constructed paths, so the compiled binary relies entirely on this registry to discover and bundle every command module. Running from source (`bun run cli.ts`) works fine without the registry entry because the dynamic import fallback succeeds, so you won't catch this locally -- it only breaks in the distributed binary. diff --git a/commands/chat.ts b/commands/chat.ts index 20b028fd2..92357f556 100644 --- a/commands/chat.ts +++ b/commands/chat.ts @@ -70,6 +70,7 @@ import { chatLeave, chatMark, chatMessages, + chatAck, chatPost, chatRead, chatRooms, @@ -667,29 +668,58 @@ async function runPost(args: string[]): Promise { // so a bare args.slice(1).join(" ") would splice the flag back into the post. const rest = positionals(args); const room = rest[0]; - if (!room) fail("usage: rt chat post [--file ] [--as-is]"); + if (!room) fail("usage: rt chat post [--file ] [--as-is] [--quiet]"); requireValidName("room", room); - const body = await resolveBody(rest.slice(1), args, "usage: rt chat post [--file ] [--as-is]"); + const body = await resolveBody(rest.slice(1), args, "usage: rt chat post [--file ] [--as-is] [--quiet]"); requireReadable(body, args); const handle = resolveHandle(args); requireValidName("handle", handle); - const res = await chatPost({ room, handle, body }); + const quiet = args.includes("--quiet"); + const res = await chatPost({ room, handle, body, quiet }, sockOpts(args)); const data = unwrap(res, "post"); // Output stays to a line or two: who was actually woken (a post that // delivered to nobody used to be silent, indistinguishable from success // at the prompt that caused it), plus the viewer link when configured. const url = chatViewerUrl(readChatViewerUrlSetting(), room, data.id); if (args.includes("--json")) { - console.log(JSON.stringify({ ok: true, id: data.id, recipients: data.recipients, url: url ?? null })); + console.log(JSON.stringify({ ok: true, id: data.id, quiet, recipients: data.recipients, url: url ?? null })); return; } - if (data.recipients.length > 0) console.log(`delivered to ${data.recipients.join(", ")}`); + // A quiet post's empty recipient list is the point, not the "woke nobody" + // failure the line below reports. + if (quiet) console.log("posted quietly (on the record, unread for every member, nobody woken)"); + else if (data.recipients.length > 0) console.log(`delivered to ${data.recipients.join(", ")}`); else console.log("delivered to nobody (no member was woken; rt chat who shows who is listening)"); if (url) console.log(`posted → ${url}`); } +/** + * The counterpart to a room post: acknowledging costs the author one wake and + * every other member nothing, where a posted "ack" wakes the whole room. The + * id comes from the delivered line (`[#room] handle #: body`). + */ +async function runAck(args: string[]): Promise { + const rest = positionals(args); + const raw = rest[0]; + if (!raw) fail("usage: rt chat ack "); + const id = Number(raw); + if (!Number.isInteger(id) || id <= 0) fail(`not a message id: ${raw} (the delivered line shows it as "#")`); + + const handle = resolveHandle(args); + requireValidName("handle", handle); + + const res = await chatAck({ id, handle }, sockOpts(args)); + const data = unwrap(res, "ack"); + if (args.includes("--json")) { + console.log(JSON.stringify({ ok: true, id, author: data.author, room: data.room, already: data.already })); + return; + } + if (data.already) console.log(`already acked #${id} (${data.author} was not woken again)`); + else console.log(`acked #${id} → ${data.author}`); +} + async function runRead(args: string[]): Promise { const room = positional(args); if (room) requireValidName("room", room); @@ -1103,9 +1133,10 @@ async function runBack(args: string[]): Promise { // ─── dispatcher ──────────────────────────────────────────────────────────────── const USAGE = - "usage: rt chat ..."; + "usage: rt chat ..."; const VERBS: Record Promise> = { + ack: runAck, join: runJoin, leave: runLeave, archive: runArchive, @@ -1126,6 +1157,7 @@ const VERBS: Record Promise> = { const VERB_HINTS: Record = { read: "show recent messages", + ack: "acknowledge one message, waking only its author", post: "send a message to a room", dm: "send a direct message to a handle", rooms: "list rooms", diff --git a/e2e/tests/chat-inbox-delivery.test.ts b/e2e/tests/chat-inbox-delivery.test.ts index 463343995..1ec5e3888 100644 --- a/e2e/tests/chat-inbox-delivery.test.ts +++ b/e2e/tests/chat-inbox-delivery.test.ts @@ -235,7 +235,7 @@ 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 #${posted.id}: @recipient hello from e2e\n` + 'reply via rt chat post "..." or rt chat dm "..." (never SendMessage; this arrived through rt chat)\n', ); }, 30_000); @@ -269,11 +269,11 @@ describe("rt chat inbox delivery (e2e)", () => { await waitForFrame(inboxB.frames, (f) => frameContent(f).includes("You're signed in")); await signIn(home, "sess-c", "c", "testroom"); - await dm(home, "a", "secret for a", "sess-c"); + const sent = 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( - '\n[dm] c: secret for a\n' + + `\n[dm] c #${sent.id}: 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. diff --git a/lib/command-tree-def.ts b/lib/command-tree-def.ts index 60f436b0f..785639dd0 100644 --- a/lib/command-tree-def.ts +++ b/lib/command-tree-def.ts @@ -798,8 +798,8 @@ export const TREE: Record = { fn: "chat", omitBehavior: "picker", args: [ - { name: "Verb", type: "text", placeholder: "join | leave | archive | post | read | rooms | who | mark | prune | sign-in | sign-out | away | back | buddies | dm | invite", hint: "The chat action to run" }, - { name: "Room", type: "text", optional: true, placeholder: "build", hint: "Room name for join/leave/archive/post/read/who/mark; the target handle for dm; the pane id for invite; omit on read/rooms/who to span everything, and on prune/sign-in/sign-out/buddies/back/away, which take no room" }, + { name: "Verb", type: "text", placeholder: "join | leave | archive | post | read | ack | rooms | who | mark | prune | sign-in | sign-out | away | back | buddies | dm | invite", hint: "The chat action to run" }, + { name: "Room", type: "text", optional: true, placeholder: "build", hint: "Room name for join/leave/archive/post/read/who/mark; the target handle for dm; the pane id for invite; the message id for ack; omit on read/rooms/who to span everything, and on prune/sign-in/sign-out/buddies/back/away, which take no room" }, { name: "Text", type: "text", optional: true, placeholder: "@handle message", hint: "A one-line message body (every word after the room/handle) — post, dm; leave it out and feed the body on stdin (a heredoc) so paragraphs and lists survive; away takes this directly, with no room before it" }, { name: "As handle", flag: "--as", type: "text", placeholder: "repo-tools-main", hint: "Override the derived handle for this invocation; refused while signed in (sign out first)" }, { name: "Wake on", flag: "--wake-on", type: "text", placeholder: "mention | all | none", hint: "For join: when this handle gets delivered a message (default mention)" }, @@ -816,7 +816,7 @@ export const TREE: Record = { { name: "Pane", flag: "--pane", type: "text", placeholder: "w1:p1", hint: "For sign-in/sign-out: target this herdr pane's Claude session daemon-side (resolved via herdr), no CLAUDE_CODE_SESSION_ID needed" }, { name: "Body file", flag: "--file", type: "text", placeholder: "post.md", hint: "For post/dm: read the body from a file instead of stdin or the text" }, { name: "As is", flag: "--as-is", type: "boolean", default: false, hint: "For post/dm: post a long single-line body anyway (500+ characters with no line break is refused by default)" }, - { name: "Quiet", flag: "--quiet", type: "boolean", default: false, hint: "For sign-out: suppress output (the SessionEnd hook's flag)" }, + { name: "Quiet", flag: "--quiet", type: "boolean", default: false, hint: "For post: put the message on the room record without waking anyone (it stays unread and catches up in a later delivery); for sign-out: suppress output (the SessionEnd hook's flag)" }, { name: "JSON", flag: "--json", type: "boolean", default: false, hint: "Emit machine-readable JSON instead of the plain rendering (join/leave/archive/post/read/rooms/who/mark/prune/buddies/dm/away/back/sign-in/sign-out/invite)" }, ], }, diff --git a/lib/daemon/__tests__/chat-delivery.test.ts b/lib/daemon/__tests__/chat-delivery.test.ts index 7db3e6d16..9e93099a4 100644 --- a/lib/daemon/__tests__/chat-delivery.test.ts +++ b/lib/daemon/__tests__/chat-delivery.test.ts @@ -101,7 +101,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${STEER}\n`], + [sock, `\n[#general] a #1: @b hi\n${STEER}\n`], ]); expect(lastReadId(h.db, "general", "b")).toBe(posted.data.id); }); @@ -301,7 +301,7 @@ test("one retry on a failed push: fails once then succeeds -- single frame, curs await waitFor(() => calls.length >= 2); expect(calls).toHaveLength(2); // the failed attempt, then the retry expect(calls[1]![1]).toBe( - `\n[#general] a: hi\n${STEER}\n`, + `\n[#general] a #1: hi\n${STEER}\n`, ); expect(lastReadId(h.db, "general", "b")).toBe(posted.data.id); await Bun.sleep(20); // give a stray badge/warn time to land before asserting their absence @@ -363,11 +363,138 @@ test("a failed delivery (both attempts) batches with the next successful one, ca releaseFirst?.(); await waitFor(() => calls.length >= 3); // attempt1 (held, fails), attempt2 (retry, fails), attempt3 (post two, succeeds and catches up both) expect(calls[2]![1]).toBe( - `\n[#general] a: one\n[#general] a: two\n${STEER}\n`, + `\n[#general] a #1: one\n[#general] a #2: two\n${STEER}\n`, ); expect(lastReadId(h.db, "general", "b")).toBe(second.data.id); }); +test("a bundle never replays the recipient's own posts back into their own pane", async () => { + const calls: Array<[string, string]> = []; + const sock = fakeSocketPath(); + const inboxDeps: InboxDeps = { + resolve: (sessionId) => (sessionId === "sess-b" ? { pid: process.pid, socketPath: sock, status: "idle" } : null), + deliver: async (socketPath, content) => { calls.push([socketPath, content]); return { ok: true }; }, + }; + const h = freshHandlers(inboxDeps); + await h["chat:sign-in"]({ sessionId: "sess-b", baseHandle: "b" }); + await settleWelcome(calls); + await h["chat:join"]({ room: "general", handle: "a" }); + await h["chat:join"]({ room: "general", handle: "b", wakeOn: "all" }); + // postMessage never self-advances the author's cursor, so b's own post sits + // in b's pending range forever and the next post by anyone else sweeps it up. + const own = await h["chat:post"]({ room: "general", handle: "b", body: "mine" }); + if (!own.ok) throw new Error("unreachable"); + await Bun.sleep(0); + calls.length = 0; + const posted = await h["chat:post"]({ room: "general", handle: "a", body: "yours" }); + if (!posted.ok) throw new Error("unreachable"); + await Bun.sleep(0); + expect(calls).toEqual([ + [sock, `\n[#general] a #2: yours\n${STEER}\n`], + ]); + expect(lastReadId(h.db, "general", "b")).toBe(posted.data.id); +}); + +test("an ack wakes only the message's author, with a one-line receipt", async () => { + const calls: Array<[string, string]> = []; + const sock = fakeSocketPath(); + const inboxDeps: InboxDeps = { + resolve: (sessionId) => (sessionId === "sess-a" ? { pid: process.pid, socketPath: sock, status: "idle" } : null), + deliver: async (socketPath, content) => { calls.push([socketPath, content]); return { ok: true }; }, + }; + const h = freshHandlers(inboxDeps); + await h["chat:sign-in"]({ sessionId: "sess-a", baseHandle: "a" }); + await settleWelcome(calls); + await h["chat:join"]({ room: "general", handle: "a" }); + await h["chat:join"]({ room: "general", handle: "b" }); + const posted = await h["chat:post"]({ room: "general", handle: "a", body: "taking the picker branch" }); + if (!posted.ok) throw new Error("unreachable"); + await Bun.sleep(0); + calls.length = 0; + const acked = await h["chat:ack"]({ id: posted.data.id, handle: "b" }); + expect(acked.ok).toBe(true); + await Bun.sleep(0); + expect(calls).toEqual([ + [sock, `\nb acknowledged your message #${posted.data.id}: "taking the picker branch"\n`], + ]); +}); + +test("a repeat ack never wakes the author a second time", async () => { + const calls: Array<[string, string]> = []; + const sock = fakeSocketPath(); + const inboxDeps: InboxDeps = { + resolve: (sessionId) => (sessionId === "sess-a" ? { pid: process.pid, socketPath: sock, status: "idle" } : null), + deliver: async (socketPath, content) => { calls.push([socketPath, content]); return { ok: true }; }, + }; + const h = freshHandlers(inboxDeps); + await h["chat:sign-in"]({ sessionId: "sess-a", baseHandle: "a" }); + await settleWelcome(calls); + await h["chat:join"]({ room: "general", handle: "a" }); + await h["chat:join"]({ room: "general", handle: "b" }); + const posted = await h["chat:post"]({ room: "general", handle: "a", body: "hi" }); + if (!posted.ok) throw new Error("unreachable"); + await Bun.sleep(0); + await h["chat:ack"]({ id: posted.data.id, handle: "b" }); + await Bun.sleep(0); + calls.length = 0; + const again = await h["chat:ack"]({ id: posted.data.id, handle: "b" }); + expect(again.ok).toBe(true); + await Bun.sleep(0); + expect(calls).toEqual([]); +}); + +test("a quiet post reaches the room record but wakes nobody", async () => { + const calls: Array<[string, string]> = []; + const sock = fakeSocketPath(); + const inboxDeps: InboxDeps = { + resolve: (sessionId) => (sessionId === "sess-b" ? { pid: process.pid, socketPath: sock, status: "idle" } : null), + deliver: async (socketPath, content) => { calls.push([socketPath, content]); return { ok: true }; }, + }; + const h = freshHandlers(inboxDeps); + await h["chat:sign-in"]({ sessionId: "sess-b", baseHandle: "b" }); + await settleWelcome(calls); + await h["chat:join"]({ room: "general", handle: "a" }); + await h["chat:join"]({ room: "general", handle: "b", wakeOn: "all" }); + const posted = await h["chat:post"]({ room: "general", handle: "a", body: "taking the picker branch", quiet: true }); + if (!posted.ok) throw new Error("unreachable"); + await Bun.sleep(0); + expect(calls).toEqual([]); + // Still unread, so peek and the viewer both show it. + expect(lastReadId(h.db, "general", "b")).toBe(0); +}); + +test("a non-boolean quiet is rejected, never coerced into silencing the post", async () => { + const h = freshHandlers(); + await h["chat:join"]({ room: "general", handle: "a" }); + const res = await h["chat:post"]({ room: "general", handle: "a", body: "hi", quiet: "false" as unknown as boolean }); + expect(res).toEqual({ ok: false, error: "quiet must be a boolean" }); +}); + +test("a quiet post rides along in the next bundle a normal post causes", async () => { + const calls: Array<[string, string]> = []; + const sock = fakeSocketPath(); + const inboxDeps: InboxDeps = { + resolve: (sessionId) => (sessionId === "sess-b" ? { pid: process.pid, socketPath: sock, status: "idle" } : null), + deliver: async (socketPath, content) => { calls.push([socketPath, content]); return { ok: true }; }, + }; + const h = freshHandlers(inboxDeps); + await h["chat:sign-in"]({ sessionId: "sess-b", baseHandle: "b" }); + await settleWelcome(calls); + await h["chat:join"]({ room: "general", handle: "a" }); + await h["chat:join"]({ room: "general", handle: "b", wakeOn: "all" }); + await h["chat:post"]({ room: "general", handle: "a", body: "quiet note", quiet: true }); + await Bun.sleep(0); + expect(calls).toEqual([]); + const loud = await h["chat:post"]({ room: "general", handle: "a", body: "loud one" }); + if (!loud.ok) throw new Error("unreachable"); + await Bun.sleep(0); + expect(calls).toHaveLength(1); + expect(calls[0]![1]).toBe( + `\n[#general] a #1: quiet note\n[#general] a #2: loud one\n${STEER}\n`, + ); + expect(lastReadId(h.db, "general", "b")).toBe(loud.data.id); +}); + test("concurrent posts to the same recipient serialize delivery so a held first send never duplicates the backlog", async () => { const calls: Array<[string, string]> = []; const sock = fakeSocketPath(); @@ -408,8 +535,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${STEER}\n`); - expect(calls[1]![1]).toBe(`\n[#general] a: two\n${STEER}\n`); + expect(calls[0]![1]).toBe(`\n[#general] a #1: one\n${STEER}\n`); + expect(calls[1]![1]).toBe(`\n[#general] a #2: two\n${STEER}\n`); expect(lastReadId(h.db, "general", "b")).toBe(second.data.id); }); @@ -455,7 +582,7 @@ test("a held first delivery that ultimately fails still lets the second carry bo await waitFor(() => calls.length >= 3); // held attempt fails, retry fails, then post two's own send catches up both expect(calls[2]![1]).toBe( - `\n[#general] a: one\n[#general] a: two\n${STEER}\n`, + `\n[#general] a #1: one\n[#general] a #2: two\n${STEER}\n`, ); expect(lastReadId(h.db, "general", "b")).toBe(second.data.id); }); @@ -530,7 +657,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${STEER}\n`]]); + expect(calls).toEqual([[sock, `\n[dm] a #1: hi\n${STEER}\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 30628c532..3747ca310 100644 --- a/lib/daemon/__tests__/inbox.test.ts +++ b/lib/daemon/__tests__/inbox.test.ts @@ -8,11 +8,11 @@ test("the default push timeout is 3000ms, not the original 1000ms that made one expect(DEFAULT_TIMEOUT_MS).toBe(3000); }); -test("renderDeliveries formats room and dm lines", () => { +test("renderDeliveries formats room and dm lines, each carrying the id rt chat ack takes", () => { expect(renderDeliveries([ - { room: "general", dm: false, handle: "max", body: "hello" }, - { room: "dm-1", dm: true, handle: "eli", body: "hi" }, - ])).toBe("[#general] max: hello\n[dm] eli: hi"); + { room: "general", dm: false, handle: "max", body: "hello", id: 12 }, + { room: "dm-1", dm: true, handle: "eli", body: "hi", id: 13 }, + ])).toBe("[#general] max #12: hello\n[dm] eli #13: hi"); }); test("wrapCrossSession produces the exact envelope Claude Code collapses on", () => { diff --git a/lib/daemon/handlers/chat.ts b/lib/daemon/handlers/chat.ts index ef90c5b8a..18055199e 100644 --- a/lib/daemon/handlers/chat.ts +++ b/lib/daemon/handlers/chat.ts @@ -7,6 +7,7 @@ import type { Database } from "bun:sqlite"; import type { Logger } from "pino"; import { + ackMessage, isValidChatName, joinRoom, leaveRoom, @@ -64,6 +65,7 @@ const defaultInboxDeps: InboxDeps = { resolve: resolveInbox, deliver: deliverToI const defaultLog = lazyChildLogger("chat"); const CHAT_COMMANDS = [ + "chat:ack", "chat:join", "chat:leave", "chat:post", @@ -206,8 +208,13 @@ async function deliverPost( const binding = deps.resolve(presence.sessionId); if (!binding || !inboxAlive(binding)) return { delivered: false, count: 0 }; const pending = pendingMessages(msg.room, recipient, msg.id, db); - if (pending.length === 0) return { delivered: false, count: 0 }; - const items = pending.map((m) => ({ room: msg.room, dm: msg.dm, handle: m.handle, body: m.body })); + // postMessage never self-advances the author's cursor, so a recipient's own + // posts stay in their pending range and every later bundle would render them + // back into their own pane. Presentational only -- markDelivered below still + // advances the cursor past them. + const others = pending.filter((m) => m.handle !== recipient); + if (others.length === 0) return { delivered: false, count: 0 }; + const items = others.map((m) => ({ room: msg.room, dm: msg.dm, handle: m.handle, body: m.body, id: m.id })); const content = wrapCrossSession(deliveryLabel(items), `${renderDeliveries(items)}\n${REPLY_STEER}`); let result = await deps.deliver(binding.socketPath, content); if (!result.ok) { @@ -218,7 +225,7 @@ async function deliverPost( // The error STRING (e.g. "timeout" vs a connect errno), not just a // boolean -- this is exactly what the incident's silent hour lacked. log.warn({ recipient, room: msg.room, err: result.error }, "chat: delivery push failed after retry"); - await reportUnreadBadge(herdr, presence.pane, pending.length); + await reportUnreadBadge(herdr, presence.pane, others.length); return { delivered: false, count: 0 }; } markDelivered(msg.room, recipient, msg.id, db); @@ -226,7 +233,34 @@ async function deliverPost( // that chat:pulse is gone -- so a recipient actively receiving messages // never goes stale enough for prunePresence to delete its row. touchLastSeen(presence.sessionId, Date.now(), db); - return { delivered: true, count: pending.length }; + return { delivered: true, count: others.length }; +} + +const ACK_BODY_PREVIEW = 80; + +/** + * A receipt, not a message: no chat_messages row, no cursor movement, no room + * fan-out. It reaches exactly one inbox, the author's, which is the whole + * point -- acknowledging costs one wake instead of the room-wide one a posted + * "ack" costs. The preview is whitespace-collapsed so a heredoc body cannot + * turn a one-line receipt into a paragraph. + */ +async function deliverAck( + db: Database, + deps: InboxDeps, + log: Logger, + args: { author: string; acker: string; messageId: number; body: string }, +): Promise { + const { author, acker, messageId, body } = args; + const presence = presenceForHandle(author, db); + if (!presence || presence.signedOutAt !== undefined) return; + const binding = deps.resolve(presence.sessionId); + if (!binding || !inboxAlive(binding)) return; + const flat = body.replace(/\s+/g, " ").trim(); + const preview = flat.length > ACK_BODY_PREVIEW ? `${flat.slice(0, ACK_BODY_PREVIEW)}...` : flat; + const content = wrapCrossSession(`${acker} (ack)`, `${acker} acknowledged your message #${messageId}: "${preview}"`); + const result = await deps.deliver(binding.socketPath, content); + if (!result.ok) log.warn({ author, acker, id: messageId, err: result.error }, "chat: ack receipt push failed"); } function chainKey(room: string, handle: string): string { @@ -309,11 +343,12 @@ export function planSweepTargets( * Mirrors recipientsFromMembers' per-member filter (lib/state/chat-store.ts) * applied to one already-fetched message instead of a room's member list: * true when `handle` would have been a recipient of `message` under the - * normal push rules for `wakeOn` (never the author; "all" is unconditional; - * "mention" needs an exact handle mention or @here). + * normal push rules for `wakeOn` (never the author; never a quiet post, which + * may only ride along in a bundle another message causes; "all" is + * unconditional; "mention" needs an exact handle mention or @here). */ -function isRecipientUnderWakeRules(message: { handle: string; mentions: string[] }, handle: string, wakeOn: WakeMode): boolean { - if (message.handle === handle || wakeOn === "none") return false; +function isRecipientUnderWakeRules(message: { handle: string; mentions: string[]; quiet?: boolean }, handle: string, wakeOn: WakeMode): boolean { + if (message.handle === handle || message.quiet || wakeOn === "none") return false; return wakeOn === "all" || message.mentions.includes("here") || message.mentions.includes(handle); } @@ -327,7 +362,7 @@ function isRecipientUnderWakeRules(message: { handle: string; mentions: string[] * decides whether the recipient should be swept at all, not which * individual messages in the batch they "should" see. */ -export function pendingIncludesRecipient(pending: Array<{ handle: string; mentions: string[] }>, handle: string, wakeOn: WakeMode): boolean { +export function pendingIncludesRecipient(pending: Array<{ handle: string; mentions: string[]; quiet?: boolean }>, handle: string, wakeOn: WakeMode): boolean { return pending.some((m) => isRecipientUnderWakeRules(m, handle, wakeOn)); } @@ -669,15 +704,15 @@ async function findPaneSessionRetrying( function postAndNotify( db: Database, emitEvent: (topic: string, payload?: unknown) => unknown, - args: { room: string; handle: string; body: string; mentions?: string[] }, + args: { room: string; handle: string; body: string; mentions?: string[]; quiet?: boolean }, inboxDeps: InboxDeps, herdr: typeof herdrRequest, deliveryChains: Map>, log: Logger, retryDelayMs: number, ): { id: number; recipients: string[] } | undefined { - const { room, handle, body, mentions } = args; - const posted = postMessage({ room, handle, body, mentions }, db); + const { room, handle, body, mentions, quiet } = args; + const posted = postMessage({ room, handle, body, mentions, quiet }, db); if (!posted) return undefined; // The row is durable at this point. The msg emit is best-effort: a throw // here (a full disk, an orphan daemon holding an events.db lock) must @@ -689,6 +724,11 @@ function postAndNotify( log.warn({ err, id: posted.id, room }, "chat: emit for the posted message threw; message is durable, this emit was not"); } const dm = dmParticipants(room, db); + // A quiet post is the record without the interruption: it stays unread (so + // peek and the viewer still surface it) and catches up inside whatever + // bundle a later ordinary message causes, but it wakes nobody itself -- + // neither an agent's inbox below nor the human's desk further down. + if (quiet) return { id: posted.id, recipients: [] }; for (const recipient of posted.recipients) { queueMicrotask(() => { deliverSerialized(deliveryChains, db, inboxDeps, herdr, log, retryDelayMs, recipient, { room, dm: dm !== null, id: posted.id }).catch((err) => { @@ -796,11 +836,14 @@ export function createChatHandlers(opts: { "chat:post": async (rawPayload: unknown): Promise> => { const payload = rawPayload as Commands["chat:post"]["payload"]; - const { room, handle, body, mentions } = payload; + const { room, handle, body, mentions, quiet } = payload; if (!isValidChatName(room)) return { ok: false, error: `invalid room "${room}"` }; if (!isValidChatName(handle)) return { ok: false, error: `invalid handle "${handle}"` }; if (!isValidBody(body)) return { ok: false, error: `body must be a non-empty string under ${MAX_BODY_BYTES} bytes` }; if (mentions !== undefined && !Array.isArray(mentions)) return { ok: false, error: "mentions must be an array of handles" }; + // Rejected rather than coerced: a truthy non-boolean (the string + // "false", say) would silently suppress every wake this post owes. + if (quiet !== undefined && typeof quiet !== "boolean") return { ok: false, error: "quiet must be a boolean" }; const invalidMention = mentions?.find((m) => !isValidChatName(m)); if (invalidMention !== undefined) return { ok: false, error: `invalid handle "${invalidMention}"` }; // A typo'd room previously no-op'd through postMessage's REVIVE (a @@ -810,11 +853,39 @@ export function createChatHandlers(opts: { const nearby = closestRoomNames(room, handle, db); return { ok: false, error: `unknown room "${room}"${nearby.length ? ` — did you mean: ${nearby.join(", ")}` : ""}` }; } - const posted = postAndNotify(db, emitEvent, { room, handle, body, mentions }, inboxDeps, herdr, deliveryChains, log, retryDelayMs); + const posted = postAndNotify(db, emitEvent, { room, handle, body, mentions, quiet }, inboxDeps, herdr, deliveryChains, log, retryDelayMs); if (!posted) return { ok: false, error: "chat: post failed (retry budget exhausted)" }; return { ok: true, data: posted }; }, + "chat:ack": async (rawPayload: unknown): Promise> => { + const payload = rawPayload as Commands["chat:ack"]["payload"]; + const { id, handle } = payload; + if (!isValidChatName(handle)) return { ok: false, error: `invalid handle "${handle}"` }; + if (!Number.isInteger(id) || id <= 0) return { ok: false, error: "id must be a positive message id" }; + const res = ackMessage({ messageId: id, handle }, db); + if (!res.ok) { + const why = + res.reason === "unknown-message" + ? `no message #${id}` + : res.reason === "own-message" + ? `message #${id} is your own` + : `you are not a member of the room message #${id} is in`; + return { ok: false, error: why }; + } + // Only a first ack owes a receipt: a repeat is already recorded, and + // re-waking the author is exactly the noise this verb exists to avoid. + if (!res.already) { + const { author, body } = res; + queueMicrotask(() => { + deliverAck(db, inboxDeps, log, { author, acker: handle, messageId: id, body }).catch((err) => { + log.warn({ err, id, handle }, "chat: ack delivery failed"); + }); + }); + } + return { ok: true, data: { author: res.author, room: res.room, already: res.already } }; + }, + "chat:read": async (rawPayload: unknown): Promise> => { const payload = rawPayload as Commands["chat:read"]["payload"]; const { handle, room, limit, sinceMs } = payload; diff --git a/lib/daemon/inbox.ts b/lib/daemon/inbox.ts index 014046bc8..d2b387f10 100644 --- a/lib/daemon/inbox.ts +++ b/lib/daemon/inbox.ts @@ -67,11 +67,17 @@ export async function deliverToInbox( return Promise.race([attempt, timeout]); } +/** + * The `#` is what `rt chat ack ` takes. It rides next to the + * handle rather than at the end of the line because a batch collapses to one + * truncated row in the terminal: an id at the end of a long body would be cut + * off exactly when a bundle makes it necessary to tell the messages apart. + */ export function renderDeliveries( - items: Array<{ room: string; dm: boolean; handle: string; body: string }>, + items: Array<{ room: string; dm: boolean; handle: string; body: string; id: number }>, ): string { return items - .map((item) => `${item.dm ? "[dm]" : `[#${item.room}]`} ${item.handle}: ${item.body}`) + .map((item) => `${item.dm ? "[dm]" : `[#${item.room}]`} ${item.handle} #${item.id}: ${item.body}`) .join("\n"); } diff --git a/lib/state/__tests__/chat-store.test.ts b/lib/state/__tests__/chat-store.test.ts index b69ac5ccd..54e992a95 100644 --- a/lib/state/__tests__/chat-store.test.ts +++ b/lib/state/__tests__/chat-store.test.ts @@ -27,6 +27,7 @@ import { markRead, parseMentions, pendingMessages, + ackMessage, postMessage, readUnread, recipientsFor, @@ -39,6 +40,34 @@ function freshDb() { return openStateDb(join(tmpdir(), `chat-test-${process.pid}-${n++}.db`)); } +test("ackMessage records an ack and reports the author it belongs to", () => { + const db = freshDb(); + joinRoom({ room: "build", handle: "a" }, db); + joinRoom({ room: "build", handle: "b" }, db); + const posted = postMessage({ room: "build", handle: "a", body: "taking the picker branch" }, db); + const res = ackMessage({ messageId: posted!.id, handle: "b" }, db); + expect(res).toEqual({ ok: true, author: "a", room: "build", body: "taking the picker branch", already: false }); +}); + +test("acking twice is idempotent and says so, so a re-ack never wakes the author again", () => { + const db = freshDb(); + joinRoom({ room: "build", handle: "a" }, db); + joinRoom({ room: "build", handle: "b" }, db); + const posted = postMessage({ room: "build", handle: "a", body: "hi" }, db); + ackMessage({ messageId: posted!.id, handle: "b" }, db); + expect(ackMessage({ messageId: posted!.id, handle: "b" }, db)).toMatchObject({ ok: true, already: true }); +}); + +test("ackMessage refuses an unknown message, a non-member, and your own message", () => { + const db = freshDb(); + joinRoom({ room: "build", handle: "a" }, db); + joinRoom({ room: "build", handle: "b" }, db); + const posted = postMessage({ room: "build", handle: "a", body: "hi" }, db); + expect(ackMessage({ messageId: 9999, handle: "b" }, db)).toEqual({ ok: false, reason: "unknown-message" }); + expect(ackMessage({ messageId: posted!.id, handle: "stranger" }, db)).toEqual({ ok: false, reason: "not-a-member" }); + expect(ackMessage({ messageId: posted!.id, handle: "a" }, db)).toEqual({ ok: false, reason: "own-message" }); +}); + test("rejects names outside the charset", () => { expect(isValidChatName("build")).toBe(true); expect(isValidChatName("acme-dev-42")).toBe(true); diff --git a/lib/state/chat-store.ts b/lib/state/chat-store.ts index dd0c94c85..935c1c295 100644 --- a/lib/state/chat-store.ts +++ b/lib/state/chat-store.ts @@ -46,6 +46,8 @@ export interface ChatMessage { mentions: string[]; replyTo?: number; postedAt: number; + /** Set only on a quiet post, so an ordinary message's shape is unchanged. */ + quiet?: boolean; } interface MemberRow { @@ -83,6 +85,7 @@ interface MessageRow { mentions: string | null; reply_to: number | null; posted_at: number; + quiet: number; } function rowToMessage(row: MessageRow): ChatMessage { @@ -95,6 +98,7 @@ function rowToMessage(row: MessageRow): ChatMessage { postedAt: row.posted_at, }; if (row.reply_to !== null) message.replyTo = row.reply_to; + if (row.quiet) message.quiet = true; return message; } @@ -128,8 +132,8 @@ export const CHAT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000; /** The newest N messages per room that pruneMessages never deletes, age floor notwithstanding -- a live room is never emptied. */ export const CHAT_ROOM_FLOOR = 200; -const MESSAGE_COLUMNS = "id, room, handle, body, mentions, reply_to, posted_at"; -const INSERT_MESSAGE_SQL = `INSERT INTO chat_messages (room, handle, body, mentions, reply_to, posted_at) VALUES (?, ?, ?, ?, ?, ?);`; +const MESSAGE_COLUMNS = "id, room, handle, body, mentions, reply_to, posted_at, quiet"; +const INSERT_MESSAGE_SQL = `INSERT INTO chat_messages (room, handle, body, mentions, reply_to, posted_at, quiet) VALUES (?, ?, ?, ?, ?, ?, ?);`; // Ranks each room's own messages newest-first (rn=1 is the newest); a row is // only a delete candidate once it falls outside the per-room floor AND past // the age cutoff -- either condition alone must keep it. @@ -185,6 +189,7 @@ WHERE chat_members.last_read_id < maxes.maxId WHERE m.room = chat_members.room AND m.id > chat_members.last_read_id AND m.handle <> chat_members.handle + AND m.quiet = 0 ); `; @@ -431,16 +436,16 @@ export function recipientsFor( } export function postMessage( - args: { room: string; handle: string; body: string; mentions?: string[] }, + args: { room: string; handle: string; body: string; mentions?: string[]; quiet?: boolean }, db: Database = getStateDb(), ): { id: number; recipients: string[] } | undefined { - const { room, handle, body } = args; + const { room, handle, body, quiet } = args; const mentions = mergeMentions(body, args.mentions); const run = db.transaction((): { id: number; recipients: string[] } => { const now = Date.now(); db.query(REVIVE_ROOM_SQL).run(room); - const result = db.query(INSERT_MESSAGE_SQL).run(room, handle, body, JSON.stringify(mentions), null, now); + const result = db.query(INSERT_MESSAGE_SQL).run(room, handle, body, JSON.stringify(mentions), null, now, quiet ? 1 : 0); const recipients = recipientsFor(room, handle, mentions, db); return { id: Number(result.lastInsertRowid), recipients }; }); @@ -599,3 +604,33 @@ export interface StalePendingRow { export function stalePendingPairs(db: Database = getStateDb()): StalePendingRow[] { return db.query(SELECT_STALE_PENDING_SQL).all() as StalePendingRow[]; } + +export type AckResult = + | { ok: true; author: string; room: string; body: string; already: boolean } + | { ok: false; reason: "unknown-message" | "not-a-member" | "own-message" }; + +/** + * Records that `handle` has acknowledged one message. `already` is what keeps + * a re-ack from waking the author a second time: the INSERT is OR IGNORE + * against the (message_id, handle) primary key, so the second call reports + * the same success without a new notification being owed. + */ +export function ackMessage( + args: { messageId: number; handle: string }, + db: Database = getStateDb(), +): AckResult { + const { messageId, handle } = args; + const msg = db + .query("SELECT room, handle, body FROM chat_messages WHERE id = ?;") + .get(messageId) as { room: string; handle: string; body: string } | null; + if (!msg) return { ok: false, reason: "unknown-message" }; + if (msg.handle === handle) return { ok: false, reason: "own-message" }; + const member = db + .query("SELECT 1 AS present FROM chat_members WHERE room = ? AND handle = ?;") + .get(msg.room, handle) as { present: number } | null; + if (!member) return { ok: false, reason: "not-a-member" }; + const result = db + .query("INSERT OR IGNORE INTO chat_acks (message_id, handle, acked_at) VALUES (?, ?, ?);") + .run(messageId, handle, Date.now()); + return { ok: true, author: msg.handle, room: msg.room, body: msg.body, already: result.changes === 0 }; +} diff --git a/lib/state/db.ts b/lib/state/db.ts index 872cba12f..2d71cb52c 100644 --- a/lib/state/db.ts +++ b/lib/state/db.ts @@ -288,6 +288,15 @@ CREATE INDEX IF NOT EXISTS agents_repo_created ON agents(repo, created_at); CREATE INDEX IF NOT EXISTS agents_created ON agents(created_at); `; +const V8_SCHEMA = ` +CREATE TABLE IF NOT EXISTS chat_acks ( + message_id INTEGER NOT NULL, + handle TEXT NOT NULL, + acked_at INTEGER NOT NULL, + PRIMARY KEY (message_id, handle) +); +`; + /** * Every schema block, in version order. `runMigrations` execs * `SCHEMAS.join("")` unconditionally on EVERY open (R015/R056): every @@ -297,7 +306,7 @@ CREATE INDEX IF NOT EXISTS agents_created ON agents(created_at); * A future schema block joins this array; leaving one out is caught by the * dynamic table-presence test in db-schema-convergence.test.ts. */ -const SCHEMAS = [V1_SCHEMA, V2_SCHEMA, V3_SCHEMA, V4_SCHEMA, V6_SCHEMA, V7_SCHEMA]; +const SCHEMAS = [V1_SCHEMA, V2_SCHEMA, V3_SCHEMA, V4_SCHEMA, V6_SCHEMA, V7_SCHEMA, V8_SCHEMA]; /** project_mr_demands.sections (v6): SQLite's ALTER TABLE ADD COLUMN has no IF NOT EXISTS, so unlike every statement in the V*_SCHEMA strings above it @@ -329,6 +338,15 @@ function addHandleColumnIfMissing(db: Database): void { db.exec("ALTER TABLE agents ADD COLUMN handle TEXT;"); } +/** chat_messages.quiet: a post that must never CAUSE a wake (it still rides + along in a bundle some other message causes). Same conditional-exec rule as + `sections`, `archived_at` and `handle` above. */ +function addQuietColumnIfMissing(db: Database): void { + const columns = db.query("PRAGMA table_info(chat_messages);").all() as { name: string }[]; + if (columns.some((c) => c.name === "quiet")) return; + db.exec("ALTER TABLE chat_messages ADD COLUMN quiet INTEGER NOT NULL DEFAULT 0;"); +} + /** * endpoint_claims.start_time (S068): the claiming pid's start-time, so a * recycled pid across a reboot reads as dead rather than pinning a port @@ -519,6 +537,7 @@ function runMigrations(db: Database, dir: string): void { addSectionsColumnIfMissing(db); addArchivedAtColumnIfMissing(db); addHandleColumnIfMissing(db); + addQuietColumnIfMissing(db); // Legacy-JSON import is single-shot and only correct from a true // v0 (never-migrated) database: branch-cache's UPSERT would silently // overwrite current rows with stale ones, and project-mrs-store's diff --git a/lib/state/index.ts b/lib/state/index.ts index 64efdcd32..b91ec0e57 100644 --- a/lib/state/index.ts +++ b/lib/state/index.ts @@ -105,6 +105,7 @@ export { } from "./run-history-store.ts"; export { + ackMessage, isValidChatName, joinRoom, leaveRoom, @@ -126,6 +127,7 @@ export { pruneMessages, CHAT_RETENTION_MS, CHAT_ROOM_FLOOR, + type AckResult, type ChatMember, type ChatMessage, type WakeMode, diff --git a/packages/rt-client/src/client.ts b/packages/rt-client/src/client.ts index b59dd0b2e..c0acd06d5 100644 --- a/packages/rt-client/src/client.ts +++ b/packages/rt-client/src/client.ts @@ -166,14 +166,26 @@ export function chatLeave( } export function chatPost( - a: { room: string; handle: string; body: string; mentions?: string[] }, + a: { room: string; handle: string; body: string; mentions?: string[]; quiet?: boolean }, o: RtClientOptions = {}, ): Promise> { const payload: Record = { room: a.room, handle: a.handle, body: a.body }; if (a.mentions !== undefined) payload.mentions = a.mentions; + if (a.quiet) payload.quiet = true; return rtCommand<{ id: number; recipients: string[] }>("chat:post", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 }); } +export function chatAck( + a: { id: number; handle: string }, + o: RtClientOptions = {}, +): Promise> { + return rtCommand<{ author: string; room: string; already: boolean }>( + "chat:ack", + { id: a.id, handle: a.handle }, + { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 }, + ); +} + export function chatRead( a: { handle: string; room?: string; limit?: number; sinceMs?: number }, o: RtClientOptions = {}, diff --git a/packages/rt-client/src/commands.ts b/packages/rt-client/src/commands.ts index 7f11bb83e..2880d98f5 100644 --- a/packages/rt-client/src/commands.ts +++ b/packages/rt-client/src/commands.ts @@ -405,7 +405,8 @@ export interface Commands { "runs:abandon": { payload: { runId: string; repo?: string; reason?: string }; data: { ok: boolean } }; "chat:join": { payload: { room: string; handle: string; wakeOn?: WakeMode; cwd?: string; pane?: string }; data: { handle: string; memberCount: number; unread: number } }; "chat:leave": { payload: { room: string; handle: string }; data: Record }; - "chat:post": { payload: { room: string; handle: string; body: string; mentions?: string[] }; data: { id: number; recipients: string[] } }; + "chat:post": { payload: { room: string; handle: string; body: string; mentions?: string[]; quiet?: boolean }; data: { id: number; recipients: string[] } }; + "chat:ack": { payload: { id: number; handle: string }; data: { author: string; room: string; already: boolean } }; "chat:read": { payload: { handle: string; room?: string; limit?: number; sinceMs?: number }; data: { rooms: { room: string; messages: ChatMessage[] }[] } }; "chat:rooms": { payload: { handle: string; includeArchived?: boolean }; data: { rooms: RoomSummary[] } }; "chat:who": { payload: { room: string }; data: { members: ChatMember[] } }; @@ -547,6 +548,7 @@ export const COMMAND_NAMES: readonly CommandName[] = [ "runs:list", "runs:get", "runs:abandon", + "chat:ack", "chat:join", "chat:leave", "chat:post", diff --git a/packages/rt-client/src/index.ts b/packages/rt-client/src/index.ts index bdae58f7c..232b6b70a 100644 --- a/packages/rt-client/src/index.ts +++ b/packages/rt-client/src/index.ts @@ -12,6 +12,7 @@ export { abandonRun, chatJoin, chatLeave, + chatAck, chatPost, chatRead, chatRooms, diff --git a/skills/rt-chat/SKILL.md b/skills/rt-chat/SKILL.md index 310c5f64d..ea4cbbdf9 100644 --- a/skills/rt-chat/SKILL.md +++ b/skills/rt-chat/SKILL.md @@ -1,6 +1,6 @@ --- name: rt:chat -description: Use when asked to join or coordinate in an agent chat room, when told you are working alongside other agents, or when you need to reach an agent under a different account, or when asked to put you and another agent into a room together (recruiting through herdr). +description: Use when asked to join or coordinate in an agent chat room, when told you are working alongside other agents, when replying to or acknowledging a message that arrived from another agent, when you need to reach one agent directly or under a different account, or when asked to put you and another agent into a room together (recruiting through herdr). --- # rt chat (agent coordination) @@ -68,10 +68,14 @@ cross-session message): ``` -[#room] handle: body +[#room] handle #: body ``` +The `#` on each line is that message's id: it is what `rt chat ack +` takes, and the only thing that tells two messages apart when +several arrive batched into one row. + 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 @@ -133,7 +137,8 @@ arrive. | `rt chat join [--wake-on mention\|all\|none]` | join an additional room; creates it if it doesn't exist. No `--as`: your handle comes from the session file | | `rt chat leave ` | drop membership | | `rt chat archive ` | park a finished room: it leaves every member's `rooms`, delivers to nobody, and any post into it reopens it for everyone. `--reopen` clears the archive without posting. Matt's call, not yours (see Archiving below) | -| `rt chat post []` | post a message: the body on stdin from a heredoc, or one line of text — see Posting a message below. Parses `@mentions`, delivers to every recipient's inbox, and prints only the message link | +| `rt chat post [] [--quiet]` | post a message: the body on stdin from a heredoc, or one line of text — see Posting a message below. Parses `@mentions`, delivers to every recipient's inbox, and prints only the message link. `--quiet` puts it on the record and wakes nobody | +| `rt chat ack ` | acknowledge one message: the author alone is woken with a one-line receipt, and the room is not touched — see Acknowledging below | | `rt chat invite --room [--note ]` | type `/chat:join ` into one herdr pane, so that agent joins itself; needs herdr. Reports `accepted` \| `queued` \| `refused`; never changes membership. The note is attributed to you | | `rt chat rooms` | rooms you're in, member counts, unread, last activity | | `rt chat mark [room]` | advance cursor without printing | @@ -154,9 +159,34 @@ down to `mention` mode it is what wakes them. `@here` delivers to every member except those in `none` mode (and never the author) — `none` always opts out, even of `@here`. -Which channel: a room post for anything the team should see (it wakes -everyone anyway); a DM for a true 1:1 (Matt silently reads those too); an -@mention inside a room post when one agent must act. +## Which channel + +Count the agents who must act on the message. That count picks the channel, +and a room post is the most expensive answer: it wakes every member, and +each woken agent then narrates, replies, and wakes the others in turn. + +| Who must act | Channel | +| --- | --- | +| One named agent | `rt chat dm ` | +| Two or three on a shared sub-task | their own room: `rt chat join ` | +| Everyone in the room | `rt chat post ` | +| Nobody, but the room should have it on the record | `rt chat post --quiet` | + +**A DM is the default.** "Message bob about xyz" is a DM. So is a question +for one agent, a handoff, a heads-up, an answer, and a two-agent +disagreement worked out to its end. Matt reads DMs too (see DMs below) and +the viewer renders them, so a DM costs nothing in visibility... it costs one +wake instead of N. + +**A room post is an announcement.** Use it when a third party would change +what they are doing because of it: a shared resource claimed, a state change +others depend on (tag pushed, branch merged, release green), a decision that +outlives the conversation. Debate in a DM or a topic room, then announce the +outcome in one post. + +A plain post wakes every member, so it needs no `@mention` to be heard. +Spend `@mentions` on the agent who must act: they are also the priority +signal on Matt's own glance surface, where a mention outranks plain unread. ## Archiving @@ -188,9 +218,14 @@ sign back in. `rt chat dm []` reaches one agent, or Matt, directly (the body comes from a heredoc, `-` on stdin, `--file`, or one line of text, -exactly as for `post`) — it finds or creates the two-participant room and posts, -delivering to the recipient unconditionally, regardless of their wake-on mode. Use it when the message is -for one specific buddy, not the room. +exactly as for `post`). It finds or creates the two-participant room and +posts, delivering to the recipient unconditionally, regardless of their +wake-on mode. This is the default channel: reach for it whenever one named +agent is the audience. + +A DM room is a real room, so it carries unread, shows up on the buddy +list's glance surface, and opens in the viewer like any other. Nothing is +hidden by choosing it. **There are no private agent↔agent DMs.** Matt is a silent third party in every agent↔agent DM: he can read it and post into it — his post delivers to @@ -222,17 +257,59 @@ breaks is refused with the heredoc hint; `--as-is` posts it anyway, stdin explicitly when a pipe is not a heredoc. `rt chat dm` takes its body the same ways. +**The body starts with the message.** Delivery already prefixes your handle +(`[#rt] kai #4821:`), so a body that opens with your own name renders as +`kai #4821: kai: ...` and pushes the line past the terminal's truncation +point. Same for a role gloss on the front (`kai (picker lane):`); if which +lane you speak for matters, it belongs in the sentence. + +```bash +rt chat post rt "remy: +1, the flag is branch-wide" # renders "remy: remy: +1..." +rt chat post rt "+1, the flag is branch-wide" # right +``` + +`--quiet` posts without waking anyone. The message still lands in the room, +still counts as unread, still opens in the viewer, and still rides along in +whatever delivery a later ordinary message causes. Use it for the record an +announcement leaves behind rather than the interruption it makes. + +## Acknowledging + +`rt chat ack ` is how you say "got it". It wakes the message's +author with a one-line receipt and touches nobody else; a repeat ack of the +same message never wakes them again. The id comes from the delivered line. + +```bash +rt chat ack 4821 +``` + +Never post an acknowledgement as a message. "ack", "+1", "confirmed", +"noted" and "will do" in a room wake every member to carry no information, +and each of those wakes costs another agent a turn. If the ack needs words +(a condition, a time, a caveat), those words are a DM to the author, not a +room post. + ## What to say in your pane -The driver of your pane sees your narration, not your tool output. Each -chat event gets exactly one line, in your own words, never a quote of the -message: +Matt reads his pane to see what YOU are doing. Chat traffic reaches him +already, through the buddy list and the viewer, and every delivered message +also costs him a collapsed row and a turn footer he cannot turn off. Your +narration is the one part of that block you control, so spend it only when +the message changed your work: | event | the line | | --- | --- | | you posted | `→ #room: ` | -| a message arrived for you | `: → ` | +| a message arrived and changed what you are doing | `: → ` | +| a message arrived and needs nothing from you | nothing | | you read the room and nothing needs you | nothing | +| you acked a message | nothing | + +Silence is the common case in a busy room, and it is correct: a room of +five agents settling something you do not own is not your event to report. +Never narrate another agent's conversation, never restate a message you were +merely copied on, and never write a line whose content is that you are still +waiting. When `chat.viewerUrl` is set, `rt chat post` prints one line ending with a link to the message you just sent: that link is how the driver reads the @@ -282,6 +359,11 @@ another agent in the room might also touch, post an announcement first This is the whole coordination mechanism — skipping it is how two agents collide on the same branch. +This is the one case where a room post beats a DM even though nobody has to +act: the point is the record every later arrival can read. Post it plainly +when someone might be mid-collision with you right now, and `--quiet` when +you just want it on the record before you start. + ## Never block on a human If you need Matt's input, `@matt` him in a room (or `rt chat dm matt ...` if diff --git a/website/docs/reference/chat.mdx b/website/docs/reference/chat.mdx index 2d8e74dc5..6f7602e98 100644 --- a/website/docs/reference/chat.mdx +++ b/website/docs/reference/chat.mdx @@ -20,7 +20,7 @@ rt chat [] [] [flags] | Flag / Arg | Type | Default | Description | | --- | --- | --- | --- | | `` | text | | The chat action to run | -| `` | text | | Room name for join/leave/archive/post/read/who/mark; the target handle for dm; the pane id for invite; omit on read/rooms/who to span everything, and on prune/sign-in/sign-out/buddies/back/away, which take no room | +| `` | text | | Room name for join/leave/archive/post/read/who/mark; the target handle for dm; the pane id for invite; the message id for ack; omit on read/rooms/who to span everything, and on prune/sign-in/sign-out/buddies/back/away, which take no room | | `` | text | | A one-line message body (every word after the room/handle) — post, dm; leave it out and feed the body on stdin (a heredoc) so paragraphs and lists survive; away takes this directly, with no room before it | | `--as` | text | | Override the derived handle for this invocation; refused while signed in (sign out first) | | `--wake-on` | text | | For join: when this handle gets delivered a message (default mention) | @@ -37,7 +37,7 @@ rt chat [] [] [flags] | `--pane` | text | | For sign-in/sign-out: target this herdr pane's Claude session daemon-side (resolved via herdr), no CLAUDE_CODE_SESSION_ID needed | | `--file` | text | | For post/dm: read the body from a file instead of stdin or the text | | `--as-is` | boolean | `false` | For post/dm: post a long single-line body anyway (500+ characters with no line break is refused by default) | -| `--quiet` | boolean | `false` | For sign-out: suppress output (the SessionEnd hook's flag) | +| `--quiet` | boolean | `false` | For post: put the message on the room record without waking anyone (it stays unread and catches up in a later delivery); for sign-out: suppress output (the SessionEnd hook's flag) | | [`--json`](/guides/common-flags) | boolean | `false` | Emit machine-readable JSON instead of the plain rendering (join/leave/archive/post/read/rooms/who/mark/prune/buddies/dm/away/back/sign-in/sign-out/invite) | _See code: [commands/chat.ts › chat](https://github.com/m4ttstack/rt/blob/main/commands/chat.ts)_