feat(broker+api+cli): topic message reply-to threading (v0.3.1)
Adds a reply_to_id column (self-FK on topic_message) plus end-to-end plumbing so a message can mark itself as a reply to a previous one in the same topic. - Schema: 0027_topic_message_reply_to.sql adds reply_to_id with ON DELETE SET NULL + index for backlink lookup. - Broker: appendTopicMessage validates parent shares the topic, writes reply_to_id; topicHistory + topic_history_response surface it; WS push envelope now carries senderMemberId, senderName, topic name, reply_to_id, and message_id so recipients have everything they need to reply without a follow-up query. - REST: POST /v1/messages accepts replyToId (validated server-side); GET /messages and SSE /stream emit it per row. - CLI: \`topic post --reply-to <id|prefix>\` resolves prefixes against recent history; \`topic tail\` renders an "↳ in reply to <name>: <snippet>" line above replies and shows a copyable #shortid tag on every row. - MCP push pipe: channel attributes now include from_pubkey, from_member_id, message_id, topic, reply_to_id — the recipient can thread a reply directly from the inbound notification. - Skill + identity prompt updated to teach Claude how to use the new attributes for replies. Bumped CLI to 1.9.0.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudemesh-cli",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
|
||||
@@ -13,6 +13,34 @@ description: Use when the user asks to send a message to a peer Claude session,
|
||||
|
||||
Respond IMMEDIATELY. Pause your current task, reply via `claudemesh send`, then resume. Read `from_name`, `mesh_slug`, and `priority` from the channel attributes. Reply by setting `<to>` to the sender's `from_name`. Do not ignore low-priority messages — acknowledge them briefly even if you defer action. If the channel meta contains `subtype: reminder`, this is a scheduled reminder you set yourself — act on it.
|
||||
|
||||
### Channel attributes (everything you need to reply is in the push)
|
||||
|
||||
The `<channel>` interrupt carries these attributes — no lookup needed:
|
||||
|
||||
| Attribute | What it is |
|
||||
|---|---|
|
||||
| `from_name` | Sender's display name. **Use as `to` in your reply** for DMs. |
|
||||
| `from_pubkey` | Sender's session pubkey (hex). Stable per-session. |
|
||||
| `from_member_id` | Sender's stable mesh.member id. Survives display-name changes — the canonical id. |
|
||||
| `mesh_slug` | Mesh the message arrived on. Pass via `--mesh <slug>` if the parent isn't on the same mesh. |
|
||||
| `priority` | `now` / `next` / `low`. |
|
||||
| `message_id` | Server-side id of THIS message. **Pass to `--reply-to <id>` to thread your reply** in topic posts. |
|
||||
| `topic` | Set when the source is a topic post. Reply via `topic post <topic> --reply-to <message_id>`. |
|
||||
| `reply_to_id` | Set when the message itself is a reply to a previous one — render thread context. |
|
||||
|
||||
**Reply patterns:**
|
||||
|
||||
```bash
|
||||
# DM → use from_name as the target
|
||||
claudemesh send "<from_name>" "ack — looking now"
|
||||
|
||||
# Topic reply → thread it onto the message you got
|
||||
claudemesh topic post "<topic>" "yep, looks good" --reply-to <message_id>
|
||||
|
||||
# When the sender is on a different mesh you've joined
|
||||
claudemesh send "<from_name>" "..." --mesh "<mesh_slug>"
|
||||
```
|
||||
|
||||
## Performance model (warm vs cold path)
|
||||
|
||||
If the parent Claude session was launched via `claudemesh launch`, an MCP push-pipe is running and holds the per-mesh WS connection. CLI invocations dial `~/.claudemesh/sockets/<mesh-slug>.sock` and reuse that warm connection (~200ms total round-trip including Node.js startup). If no push-pipe is running (cron, scripts, hooks fired outside a session), the CLI opens its own WS, which takes ~500-700ms cold. **You don't manage this** — every verb auto-detects and falls through.
|
||||
@@ -62,8 +90,14 @@ claudemesh topic tail deploys --limit 50
|
||||
# v1.8.0+: encrypted REST send (body_version 2). Falls back to v1
|
||||
# automatically for legacy unencrypted topics. --plaintext forces v1.
|
||||
claudemesh topic post deploys "rolling out, cc @Alexis stay around"
|
||||
|
||||
# v1.9.0+: thread a reply onto a previous topic message. Accepts the
|
||||
# full id or an 8+ char prefix; resolved against recent history.
|
||||
claudemesh topic post deploys "yes — same here" --reply-to 7XtIeF7o
|
||||
```
|
||||
|
||||
In `topic tail` output, replies render with a `↳ in reply to <name>: "<snippet>"` line above the message and every row shows a short id tag (`#xxxxxxxx`) so you can copy-paste into `--reply-to`.
|
||||
|
||||
When to use topics vs groups vs DM:
|
||||
- **DM** (`send <peer>`) — 1:1, ephemeral.
|
||||
- **Group** (`send "@frontend"`) — addresses everyone in a group; ephemeral; for coordinating teams.
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface TopicPostFlags {
|
||||
json?: boolean;
|
||||
/** Force v1 plaintext send even if the topic is encrypted. */
|
||||
plaintext?: boolean;
|
||||
/** Reply-to message id (full or 8+ char prefix). */
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
interface PostResponse {
|
||||
@@ -37,6 +39,7 @@ interface PostResponse {
|
||||
topic: string;
|
||||
topicId: string;
|
||||
notifications: number;
|
||||
replyToId?: string | null;
|
||||
}
|
||||
|
||||
export async function runTopicPost(
|
||||
@@ -101,6 +104,36 @@ export async function runTopicPost(
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve reply-to: accept full id or 8+ char prefix by querying recent
|
||||
// history once and matching. Server validates same-topic membership.
|
||||
let replyToId: string | undefined;
|
||||
if (flags.replyTo) {
|
||||
if (flags.replyTo.length >= 16) {
|
||||
replyToId = flags.replyTo;
|
||||
} else if (flags.replyTo.length >= 6) {
|
||||
const recent = await request<{
|
||||
messages: Array<{ id: string }>;
|
||||
}>({
|
||||
path: `/api/v1/topics/${encodeURIComponent(cleanName)}/messages?limit=200`,
|
||||
method: "GET",
|
||||
token: secret,
|
||||
});
|
||||
const hit = recent.messages?.find((r) =>
|
||||
r.id.startsWith(flags.replyTo!),
|
||||
);
|
||||
if (!hit) {
|
||||
render.err(
|
||||
`--reply-to ${flags.replyTo}: no recent message id starts with that prefix`,
|
||||
);
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
replyToId = hit.id;
|
||||
} else {
|
||||
render.err("--reply-to needs at least 6 characters of the message id");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await request<PostResponse>({
|
||||
path: "/api/v1/messages",
|
||||
method: "POST",
|
||||
@@ -111,6 +144,7 @@ export async function runTopicPost(
|
||||
nonce,
|
||||
bodyVersion,
|
||||
...(mentions.length > 0 ? { mentions } : {}),
|
||||
...(replyToId ? { replyToId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -120,9 +154,12 @@ export async function runTopicPost(
|
||||
}
|
||||
|
||||
const versionTag = bodyVersion === 2 ? green("🔒 v2") : dim("v1");
|
||||
const replyTag = result.replyToId
|
||||
? ` ${dim("↳ " + result.replyToId.slice(0, 8))}`
|
||||
: "";
|
||||
render.ok(
|
||||
"posted",
|
||||
`${clay("#" + cleanName)} ${versionTag} ${dim(`(${result.notifications} mentions)`)}`,
|
||||
`${clay("#" + cleanName)} ${versionTag}${replyTag} ${dim(`(${result.notifications} mentions)`)}`,
|
||||
);
|
||||
return EXIT.SUCCESS;
|
||||
},
|
||||
|
||||
@@ -27,14 +27,34 @@ export interface TopicTailFlags {
|
||||
|
||||
interface TopicMessage {
|
||||
id: string;
|
||||
senderMemberId?: string;
|
||||
senderPubkey: string;
|
||||
senderName: string;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
bodyVersion?: number;
|
||||
replyToId?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Bounded recent-message cache used to render reply-context lines. */
|
||||
type RenderedSnippet = { name: string; snippet: string };
|
||||
const RECENT_CACHE_MAX = 256;
|
||||
function rememberRendered(
|
||||
cache: Map<string, RenderedSnippet>,
|
||||
m: TopicMessage,
|
||||
text: string,
|
||||
): void {
|
||||
cache.set(m.id, {
|
||||
name: m.senderName || m.senderPubkey.slice(0, 8),
|
||||
snippet: text.replace(/\s+/g, " ").slice(0, 60),
|
||||
});
|
||||
if (cache.size > RECENT_CACHE_MAX) {
|
||||
const firstKey = cache.keys().next().value;
|
||||
if (firstKey) cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
interface HistoryResponse {
|
||||
topic: string;
|
||||
topicId: string;
|
||||
@@ -79,16 +99,27 @@ async function printMessage(
|
||||
m: TopicMessage,
|
||||
topicKey: Uint8Array | null,
|
||||
json: boolean,
|
||||
cache: Map<string, RenderedSnippet>,
|
||||
): Promise<void> {
|
||||
const text = await decryptForRender(m, topicKey);
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ ...m, message: text }));
|
||||
rememberRendered(cache, m, text);
|
||||
return;
|
||||
}
|
||||
const v2Marker = (m.bodyVersion ?? 1) === 2 ? dim("🔒 ") : "";
|
||||
if (m.replyToId) {
|
||||
const parent = cache.get(m.replyToId);
|
||||
const ref = parent
|
||||
? `${parent.name}: "${parent.snippet}${parent.snippet.length === 60 ? "…" : ""}"`
|
||||
: `${m.replyToId.slice(0, 8)}…`;
|
||||
process.stdout.write(` ${dim("↳ in reply to " + ref)}\n`);
|
||||
}
|
||||
const idTag = dim(`#${m.id.slice(0, 8)}`);
|
||||
process.stdout.write(
|
||||
` ${dim(fmtTime(m.createdAt))} ${bold(m.senderName || m.senderPubkey.slice(0, 8))} ${v2Marker}${text}\n`,
|
||||
` ${dim(fmtTime(m.createdAt))} ${bold(m.senderName || m.senderPubkey.slice(0, 8))} ${idTag} ${v2Marker}${text}\n`,
|
||||
);
|
||||
rememberRendered(cache, m, text);
|
||||
}
|
||||
|
||||
interface SseEvent {
|
||||
@@ -153,6 +184,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise
|
||||
topicName: cleanName,
|
||||
});
|
||||
const topicKey = keyResult.ok ? keyResult.topicKey ?? null : null;
|
||||
const snippetCache = new Map<string, RenderedSnippet>();
|
||||
|
||||
// Re-seal background loop. While we hold the topic key, every
|
||||
// 30s we look for newly-joined members who don't have a sealed
|
||||
@@ -241,7 +273,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise
|
||||
}
|
||||
// History is newest-first; reverse for chronological display.
|
||||
for (const m of history.messages.slice().reverse()) {
|
||||
await printMessage(m, topicKey, flags.json ?? false);
|
||||
await printMessage(m, topicKey, flags.json ?? false, snippetCache);
|
||||
}
|
||||
} catch (err) {
|
||||
render.warn(`backfill failed: ${(err as Error).message}`);
|
||||
@@ -283,7 +315,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise
|
||||
if (ev.event === "message") {
|
||||
try {
|
||||
const m = JSON.parse(ev.data) as TopicMessage;
|
||||
await printMessage(m, topicKey, flags.json ?? false);
|
||||
await printMessage(m, topicKey, flags.json ?? false, snippetCache);
|
||||
} catch {
|
||||
// skip malformed
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ Topic (conversation scope, v0.2.0)
|
||||
claudemesh topic history <t> fetch message history [--limit --before]
|
||||
claudemesh topic read <topic> mark all as read
|
||||
claudemesh topic tail <topic> live SSE tail [--limit --forward-only]
|
||||
claudemesh topic post <t> <msg> encrypted REST post (v0.3.0 v2)
|
||||
claudemesh topic post <t> <msg> encrypted REST post (v0.3.0 v2) [--reply-to <id>]
|
||||
claudemesh send "#topic" "msg" send to a topic (WS path, v1 plaintext)
|
||||
claudemesh member list mesh roster with online state [--online]
|
||||
claudemesh notification list recent @-mentions of you [--since <ISO>]
|
||||
@@ -592,6 +592,7 @@ async function main(): Promise<void> {
|
||||
mesh: flags.mesh as string,
|
||||
json: !!flags.json,
|
||||
plaintext: !!flags.plaintext,
|
||||
replyTo: (flags["reply-to"] as string) || (flags.replyTo as string),
|
||||
};
|
||||
const message = positionals.slice(2).join(" ");
|
||||
const { runTopicPost } = await import("~/commands/topic-post.js");
|
||||
|
||||
@@ -293,7 +293,16 @@ export async function startMcpServer(): Promise<void> {
|
||||
You are "${myName}"${myRole ? ` (${myRole})` : ""} — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority.
|
||||
|
||||
## Responding to messages
|
||||
When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Pause your current task, reply via send_message, then resume. Read from_name, mesh_slug, and priority from the channel attributes. Reply by setting \`to\` to the sender's from_name (display name). Stay in character per your system prompt. Do not ignore low-priority messages — acknowledge them briefly even if you defer action.
|
||||
When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Pause your current task, reply via send_message (or \`claudemesh topic post --reply-to <message_id>\` for topic threads), then resume. Stay in character per your system prompt. Do not ignore low-priority messages — acknowledge them briefly even if you defer action.
|
||||
|
||||
The channel attributes carry everything you need to reply — no extra lookups:
|
||||
- \`from_name\` — sender display name. Use as the \`to\` arg when replying to a DM.
|
||||
- \`from_pubkey\` / \`from_member_id\` — stable ids. Use \`from_member_id\` if the sender's display name might change.
|
||||
- \`mesh_slug\` — pass via \`--mesh\` if your default mesh differs.
|
||||
- \`priority\` — \`now\` / \`next\` / \`low\`.
|
||||
- \`message_id\` — id of THIS message. To thread a reply onto it in a topic, run \`claudemesh topic post <topic> "<text>" --reply-to <message_id>\`.
|
||||
- \`topic\` — set when the message arrived through a topic (vs DM). Reply in the same topic.
|
||||
- \`reply_to_id\` — set when the incoming message is itself a reply. Render thread context if you re-narrate.
|
||||
|
||||
If the channel meta contains \`subtype: reminder\`, this is a scheduled reminder you set for yourself — act on it immediately (no reply needed).
|
||||
|
||||
@@ -678,13 +687,18 @@ Your message mode is "${messageMode}".
|
||||
content,
|
||||
meta: {
|
||||
from_id: fromPubkey,
|
||||
from_pubkey: fromPubkey,
|
||||
from_name: fromName,
|
||||
...(msg.senderMemberId ? { from_member_id: msg.senderMemberId } : {}),
|
||||
mesh_slug: client.meshSlug,
|
||||
mesh_id: client.meshId,
|
||||
priority: msg.priority,
|
||||
sent_at: msg.createdAt,
|
||||
delivered_at: msg.receivedAt,
|
||||
kind: msg.kind,
|
||||
message_id: msg.messageId,
|
||||
...(msg.topic ? { topic: msg.topic } : {}),
|
||||
...(msg.replyToId ? { reply_to_id: msg.replyToId } : {}),
|
||||
...(msg.subtype ? { subtype: msg.subtype } : {}),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -101,6 +101,14 @@ export interface InboundPush {
|
||||
messageId: string;
|
||||
meshId: string;
|
||||
senderPubkey: string;
|
||||
/** Stable mesh.member id of the sender — preferred id for replies. */
|
||||
senderMemberId?: string;
|
||||
/** Sender's current display name (a join from the broker). */
|
||||
senderName?: string;
|
||||
/** Topic name when the push originated from a topic post (vs DM). */
|
||||
topic?: string;
|
||||
/** Server-side id of the parent message when this push is a reply. */
|
||||
replyToId?: string;
|
||||
priority: Priority;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
@@ -2028,6 +2036,10 @@ export class BrokerClient {
|
||||
messageId: String(msg.messageId ?? ""),
|
||||
meshId: String(msg.meshId ?? ""),
|
||||
senderPubkey,
|
||||
...(msg.senderMemberId ? { senderMemberId: String(msg.senderMemberId) } : {}),
|
||||
...(msg.senderName ? { senderName: String(msg.senderName) } : {}),
|
||||
...(msg.topic ? { topic: String(msg.topic) } : {}),
|
||||
...(msg.replyToId ? { replyToId: String(msg.replyToId) } : {}),
|
||||
priority: (msg.priority as Priority) ?? "next",
|
||||
nonce,
|
||||
ciphertext,
|
||||
|
||||
Reference in New Issue
Block a user