feat(cli): 1.32.0 — multi-session UX bundle (self-identity, --self fan-out, broker welcome)
Nine UX bugs surfaced from a real two-session interconnect smoke test, shipped together. Self-identity is visible - peer list now shows the caller as (this session), sorted to top. Daemon path resolves session pubkey via /v1/sessions/me so isThisSession is set correctly warm. - whoami shows session pubkey, session id, mesh, role, groups, cwd, pid when run inside a launched session. Sibling-session disambiguation - peer list rows carry sid:<short> tag so visually-identical rows can be told apart at a glance. Daemon hidden by default - claudemesh-daemon presence rows hidden from peer list by default. --all opts back in. Header shows N daemon hidden when applicable. --self flag works end-to-end - Argv parser was greedy: --self ate the next arg as its value. BOOLEAN_FLAGS set in cli/argv.ts now lists known no-value switches. - message send subcommand now passes self through (only legacy send was wired before). - Help text lists --self. Member-pubkey fan-out - Sending to your own member pubkey with --self now resolves to every connected sibling session and sends one message per recipient. Required because the broker drain matches target_spec only against full session pubkeys; member-pubkey sends queued but never drained. Broker welcome at launch - After the launch banner, one line confirms WS state, peer count, and unread inbox count. Best-effort — falls back gracefully. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,75 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.32.0 (2026-05-04) — multi-session UX bundle
|
||||||
|
|
||||||
|
Nine UX bugs surfaced from a real two-session interconnect smoke test
|
||||||
|
shipped together as a single release.
|
||||||
|
|
||||||
|
### Self-identity is now visible
|
||||||
|
|
||||||
|
- **`peer list` includes the calling session as a row**, marked
|
||||||
|
`(this session)`, sorted to the top. The daemon path now resolves the
|
||||||
|
caller's session pubkey via `/v1/sessions/me` so `isThisSession`
|
||||||
|
is set correctly even when running warm. (Previously the row was
|
||||||
|
present but indistinguishable, and the daemon path always set
|
||||||
|
`isThisSession=false`.)
|
||||||
|
- **`whoami` shows in-session identity** when run inside a launched
|
||||||
|
session: session pubkey (truncated + full), session id, mesh, role,
|
||||||
|
groups, cwd, pid. Previously whoami only reported web sign-in state.
|
||||||
|
|
||||||
|
### Sibling-session disambiguation
|
||||||
|
|
||||||
|
- **`peer list` rows now carry a `sid:<short>` tag** so two
|
||||||
|
visually-identical rows (same name, same cwd) can be told apart at
|
||||||
|
a glance.
|
||||||
|
- **JSON output already had `sessionId`**; the human renderer
|
||||||
|
surfaces a short prefix.
|
||||||
|
|
||||||
|
### Daemon presence hidden by default
|
||||||
|
|
||||||
|
- `claudemesh-daemon` rows used to clutter `peer list` and confused
|
||||||
|
users into thinking the daemon counted as a peer. They're now hidden
|
||||||
|
in the human renderer; `--all` opts back in for debugging. The header
|
||||||
|
line shows `(N peers, M daemon hidden — use --all)` when applicable.
|
||||||
|
JSON output is unchanged.
|
||||||
|
|
||||||
|
### `--self` flag works end-to-end
|
||||||
|
|
||||||
|
- **Argv parser bug fixed.** `--self` was being parsed greedily — every
|
||||||
|
`--flag` consumed the next non-`-` arg as its value, so
|
||||||
|
`claudemesh send --self <pubkey> "msg"` ate the pubkey as the value
|
||||||
|
of `--self` and left zero positionals. A `BOOLEAN_FLAGS` set in
|
||||||
|
`cli/argv.ts` now lists known no-value switches (`self`, `json`,
|
||||||
|
`all`, `quiet`, `yes`, `strict`, `force`, `dry-run`, etc.).
|
||||||
|
`--flag=value` form also recognized for explicit overrides.
|
||||||
|
- **`message send` subcommand now passes `self`** through to `runSend`
|
||||||
|
(only the legacy `send` form had been wired).
|
||||||
|
- **Help text updated** to list `--self` (and `--priority`, `--mesh`,
|
||||||
|
`--json`) under `claudemesh message send`.
|
||||||
|
|
||||||
|
### Member-pubkey fan-out
|
||||||
|
|
||||||
|
- **Sending to your own member pubkey with `--self` now fans out** to
|
||||||
|
every connected sibling session of your member. Previously the broker
|
||||||
|
drain query at `apps/broker/src/broker.ts:2408` matched
|
||||||
|
`target_spec` only against full session pubkeys, so member-pubkey
|
||||||
|
sends queued successfully but no recipient drain ever fetched. The
|
||||||
|
CLI now resolves the member pubkey to all sibling session pubkeys
|
||||||
|
via the peer list and sends one message per recipient. Output reports
|
||||||
|
`fanned out to N sibling sessions` with per-recipient ack/error.
|
||||||
|
|
||||||
|
### Broker welcome at launch
|
||||||
|
|
||||||
|
- After the launch banner, a single line confirms WS connectivity:
|
||||||
|
|
||||||
|
```
|
||||||
|
● broker connected · 6 peers online · 0 unread
|
||||||
|
```
|
||||||
|
|
||||||
|
Hits `/v1/health` for broker WS state, `peer list` (daemon-cached)
|
||||||
|
for peer count, and `/v1/inbox` for unread. All best-effort — falls
|
||||||
|
back gracefully if any call fails so launch never blocks on it.
|
||||||
|
|
||||||
## 1.31.6 (2026-05-04) — hex-prefix sends actually deliver now
|
## 1.31.6 (2026-05-04) — hex-prefix sends actually deliver now
|
||||||
|
|
||||||
`claudemesh send <16-hex-prefix> "..."` would acknowledge with `sent
|
`claudemesh send <16-hex-prefix> "..."` would acknowledge with `sent
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "1.31.6",
|
"version": "1.32.0",
|
||||||
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
|||||||
@@ -2,6 +2,35 @@ import { defineCommand, runMain } from "citty";
|
|||||||
|
|
||||||
export interface ParsedArgs { command: string; positionals: string[]; flags: Record<string, string | boolean | undefined>; }
|
export interface ParsedArgs { command: string; positionals: string[]; flags: Record<string, string | boolean | undefined>; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flags that NEVER take a value. The parser's default behavior is greedy
|
||||||
|
* (any `--flag` consumes the next non-`-` arg as its value), which is
|
||||||
|
* fine for `--mesh foo` and `--priority now` but breaks for booleans:
|
||||||
|
* `claudemesh send --self <pubkey> "msg"` was eating the pubkey as the
|
||||||
|
* value of --self, leaving zero positionals and triggering Usage errors.
|
||||||
|
*
|
||||||
|
* Adding to this set: any new boolean / no-arg switch.
|
||||||
|
*/
|
||||||
|
const BOOLEAN_FLAGS = new Set([
|
||||||
|
"self",
|
||||||
|
"json", // also accepts --json=a,b,c form below
|
||||||
|
"all",
|
||||||
|
"yes", "y",
|
||||||
|
"help", "h",
|
||||||
|
"version", "v",
|
||||||
|
"quiet",
|
||||||
|
"strict",
|
||||||
|
"continue",
|
||||||
|
"no-daemon",
|
||||||
|
"no-color",
|
||||||
|
"debug",
|
||||||
|
"allow-ci-persistent",
|
||||||
|
"force",
|
||||||
|
"dry-run",
|
||||||
|
"verbose",
|
||||||
|
"skip-service",
|
||||||
|
]);
|
||||||
|
|
||||||
export function parseArgv(argv: string[]): ParsedArgs {
|
export function parseArgv(argv: string[]): ParsedArgs {
|
||||||
const args = argv.slice(2);
|
const args = argv.slice(2);
|
||||||
const flags: Record<string, string | boolean | undefined> = {};
|
const flags: Record<string, string | boolean | undefined> = {};
|
||||||
@@ -10,14 +39,26 @@ export function parseArgv(argv: string[]): ParsedArgs {
|
|||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
const arg = args[i]!;
|
const arg = args[i]!;
|
||||||
|
// --flag=value (always parsed as a value, regardless of boolean set)
|
||||||
|
if (arg.startsWith("--") && arg.includes("=")) {
|
||||||
|
const eq = arg.indexOf("=");
|
||||||
|
const key = arg.slice(2, eq);
|
||||||
|
flags[key] = arg.slice(eq + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (arg.startsWith("--")) {
|
if (arg.startsWith("--")) {
|
||||||
const key = arg.slice(2);
|
const key = arg.slice(2);
|
||||||
|
// Known boolean → never consume the next token as a value.
|
||||||
|
if (BOOLEAN_FLAGS.has(key)) { flags[key] = true; continue; }
|
||||||
const next = args[i + 1];
|
const next = args[i + 1];
|
||||||
if (next && !next.startsWith("-")) { flags[key] = next; i++; } else flags[key] = true;
|
if (next !== undefined && !next.startsWith("-")) { flags[key] = next; i++; }
|
||||||
|
else flags[key] = true;
|
||||||
} else if (arg.startsWith("-") && arg.length === 2) {
|
} else if (arg.startsWith("-") && arg.length === 2) {
|
||||||
const key = arg.slice(1);
|
const key = arg.slice(1);
|
||||||
|
if (BOOLEAN_FLAGS.has(key)) { flags[key] = true; continue; }
|
||||||
const next = args[i + 1];
|
const next = args[i + 1];
|
||||||
if (next && !next.startsWith("-")) { flags[key] = next; i++; } else flags[key] = true;
|
if (next !== undefined && !next.startsWith("-")) { flags[key] = next; i++; }
|
||||||
|
else flags[key] = true;
|
||||||
} else if (!command) {
|
} else if (!command) {
|
||||||
command = arg;
|
command = arg;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -349,6 +349,64 @@ async function runLaunchWizard(opts: {
|
|||||||
return { mesh, role, groups, messageMode, skipPermissions };
|
return { mesh, role, groups, messageMode, skipPermissions };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1.32.0 — broker welcome line printed right after the launch banner.
|
||||||
|
* Polls the daemon's /v1/health (per-mesh broker WS state) and tries
|
||||||
|
* to fetch the inbox + peer count via daemon-route helpers. Best-effort:
|
||||||
|
* if any call fails the welcome simply prints what it knows and moves
|
||||||
|
* on — never blocks the launch path.
|
||||||
|
*/
|
||||||
|
async function printBrokerWelcome(meshSlug: string): Promise<void> {
|
||||||
|
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
|
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
|
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[22m` : s);
|
||||||
|
const yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[22m` : s);
|
||||||
|
|
||||||
|
// Probe daemon health for broker WS state.
|
||||||
|
let brokerState = "unknown";
|
||||||
|
try {
|
||||||
|
const { ipc } = await import("~/daemon/ipc/client.js");
|
||||||
|
const res = await ipc<{ ok?: boolean; brokers?: Record<string, string> }>({
|
||||||
|
path: "/v1/health",
|
||||||
|
timeoutMs: 1_500,
|
||||||
|
});
|
||||||
|
if (res.status === 200 && res.body?.brokers) {
|
||||||
|
brokerState = res.body.brokers[meshSlug] ?? "unknown";
|
||||||
|
}
|
||||||
|
} catch { /* daemon unreachable — not fatal */ }
|
||||||
|
|
||||||
|
// Peer count (best-effort).
|
||||||
|
let peerCount = -1;
|
||||||
|
try {
|
||||||
|
const { tryListPeersViaDaemon } = await import("~/services/bridge/daemon-route.js");
|
||||||
|
const peers = (await tryListPeersViaDaemon()) ?? [];
|
||||||
|
peerCount = peers.filter((p) =>
|
||||||
|
(p as { channel?: string }).channel !== "claudemesh-daemon",
|
||||||
|
).length;
|
||||||
|
} catch { /* skip peer count */ }
|
||||||
|
|
||||||
|
// Unread inbox count (best-effort).
|
||||||
|
let unread = -1;
|
||||||
|
try {
|
||||||
|
const { ipc } = await import("~/daemon/ipc/client.js");
|
||||||
|
const res = await ipc<{ messages?: unknown[] }>({
|
||||||
|
path: "/v1/inbox",
|
||||||
|
timeoutMs: 1_500,
|
||||||
|
});
|
||||||
|
if (res.status === 200 && Array.isArray(res.body?.messages)) {
|
||||||
|
unread = res.body.messages.length;
|
||||||
|
}
|
||||||
|
} catch { /* skip unread */ }
|
||||||
|
|
||||||
|
const dot = brokerState === "open" ? green("●") : yellow("●");
|
||||||
|
const parts: string[] = [];
|
||||||
|
parts.push(`broker ${brokerState === "open" ? "connected" : brokerState}`);
|
||||||
|
if (peerCount >= 0) parts.push(`${peerCount} peer${peerCount === 1 ? "" : "s"} online`);
|
||||||
|
if (unread >= 0) parts.push(`${unread} unread`);
|
||||||
|
console.log(`${dot} ${parts.join(dim(" · "))}`);
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[], messageMode: "push" | "inbox" | "off"): void {
|
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[], messageMode: "push" | "inbox" | "off"): void {
|
||||||
const useColor =
|
const useColor =
|
||||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
@@ -752,6 +810,10 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
|||||||
// 5. Print summary banner (wizard already handled all interactive config).
|
// 5. Print summary banner (wizard already handled all interactive config).
|
||||||
if (!args.quiet) {
|
if (!args.quiet) {
|
||||||
printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
|
printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
|
||||||
|
// 1.32.0+: broker welcome — confirm the per-session WS is actually
|
||||||
|
// attached and surface peer count + unread inbox so the user lands
|
||||||
|
// in claude code with a clear state instead of silent assumptions.
|
||||||
|
await printBrokerWelcome(mesh.slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Install native MCP entries for deployed mesh services ---
|
// --- Install native MCP entries for deployed mesh services ---
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ export interface PeersFlags {
|
|||||||
mesh?: string;
|
mesh?: string;
|
||||||
/** `true`/`undefined` = full record; comma-separated string = field projection. */
|
/** `true`/`undefined` = full record; comma-separated string = field projection. */
|
||||||
json?: boolean | string;
|
json?: boolean | string;
|
||||||
|
/** When false (default), hide claudemesh-daemon presence rows from the
|
||||||
|
* human renderer — they're infrastructure, not interactive peers, and
|
||||||
|
* confused users into thinking the daemon counted as a "peer". The
|
||||||
|
* JSON output still includes them so scripts that need a full inventory
|
||||||
|
* can opt in via --all (or just consume JSON). */
|
||||||
|
all?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PeerRecord {
|
interface PeerRecord {
|
||||||
@@ -29,6 +35,10 @@ interface PeerRecord {
|
|||||||
* this with a peer, they're talking to the same person across all
|
* this with a peer, they're talking to the same person across all
|
||||||
* their open sessions. */
|
* their open sessions. */
|
||||||
memberPubkey?: string;
|
memberPubkey?: string;
|
||||||
|
/** Per-launch session identifier (uuid). Used by the renderer to
|
||||||
|
* disambiguate sibling sessions of the same member that otherwise
|
||||||
|
* look identical (same name, same cwd). */
|
||||||
|
sessionId?: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
@@ -82,6 +92,20 @@ async function listPeersForMesh(slug: string): Promise<PeerRecord[]> {
|
|||||||
const joined = config.meshes.find((m) => m.slug === slug);
|
const joined = config.meshes.find((m) => m.slug === slug);
|
||||||
const selfMemberPubkey = joined?.pubkey ?? null;
|
const selfMemberPubkey = joined?.pubkey ?? null;
|
||||||
|
|
||||||
|
// Resolve our own session pubkey via the daemon's /v1/sessions/me when
|
||||||
|
// we're inside a launched session. Without this, isThisSession can't
|
||||||
|
// be set on the daemon path (only on the cold path where a fresh WS
|
||||||
|
// creates the keypair), and the renderer can't tell the user which
|
||||||
|
// row in `peer list` is them.
|
||||||
|
let selfSessionPubkey: string | null = null;
|
||||||
|
try {
|
||||||
|
const { getSessionInfo } = await import("~/services/session/resolve.js");
|
||||||
|
const sess = await getSessionInfo();
|
||||||
|
if (sess && sess.mesh === slug && sess.presence?.sessionPubkey) {
|
||||||
|
selfSessionPubkey = sess.presence.sessionPubkey;
|
||||||
|
}
|
||||||
|
} catch { /* not in a launched session; isThisSession stays false */ }
|
||||||
|
|
||||||
// Daemon path — preferred when running. Same routing pattern as send.ts:
|
// Daemon path — preferred when running. Same routing pattern as send.ts:
|
||||||
// ~1 ms IPC round-trip; broker WS already warm in the daemon. The
|
// ~1 ms IPC round-trip; broker WS already warm in the daemon. The
|
||||||
// lifecycle helper inside tryListPeersViaDaemon auto-spawns the
|
// lifecycle helper inside tryListPeersViaDaemon auto-spawns the
|
||||||
@@ -91,7 +115,7 @@ async function listPeersForMesh(slug: string): Promise<PeerRecord[]> {
|
|||||||
const { tryListPeersViaDaemon } = await import("~/services/bridge/daemon-route.js");
|
const { tryListPeersViaDaemon } = await import("~/services/bridge/daemon-route.js");
|
||||||
const dr = await tryListPeersViaDaemon();
|
const dr = await tryListPeersViaDaemon();
|
||||||
if (dr !== null) {
|
if (dr !== null) {
|
||||||
return dr.map((p) => annotateSelf(p as PeerRecord, selfMemberPubkey, null));
|
return dr.map((p) => annotateSelf(p as PeerRecord, selfMemberPubkey, selfSessionPubkey));
|
||||||
}
|
}
|
||||||
} catch { /* daemon route helper not available; fall through */ }
|
} catch { /* daemon route helper not available; fall through */ }
|
||||||
|
|
||||||
@@ -184,14 +208,36 @@ export async function runPeers(flags: PeersFlags): Promise<void> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
render.section(`peers on ${slug} (${peers.length})`);
|
// Hide claudemesh-daemon rows by default — they're infrastructure
|
||||||
|
// (the daemon's own member-keyed presence), not interactive peers,
|
||||||
|
// and they confused users into thinking the daemon counted as a
|
||||||
|
// separate peer. --all opts back in for debugging.
|
||||||
|
const visible = flags.all
|
||||||
|
? peers
|
||||||
|
: peers.filter((p) => p.channel !== "claudemesh-daemon");
|
||||||
|
|
||||||
if (peers.length === 0) {
|
// Sort: this-session first, then your-other-sessions, then real
|
||||||
|
// peers. Within each group, idle/working ahead of dnd. Inside the
|
||||||
|
// groups, leave broker order. The point is: when you run peer
|
||||||
|
// list, the row that's YOU is row 1.
|
||||||
|
const sorted = visible.slice().sort((a, b) => {
|
||||||
|
const score = (p: PeerRecord) =>
|
||||||
|
p.isThisSession ? 0 : p.isSelf ? 1 : 2;
|
||||||
|
return score(a) - score(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hiddenDaemons = peers.length - visible.length;
|
||||||
|
const header = hiddenDaemons > 0
|
||||||
|
? `peers on ${slug} (${sorted.length}, ${hiddenDaemons} daemon hidden — use --all)`
|
||||||
|
: `peers on ${slug} (${sorted.length})`;
|
||||||
|
render.section(header);
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
render.info(dim(" (no peers connected)"));
|
render.info(dim(" (no peers connected)"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const p of peers) {
|
for (const p of sorted) {
|
||||||
const statusDot = p.status === "working" ? yellow("●") : green("●");
|
const statusDot = p.status === "working" ? yellow("●") : green("●");
|
||||||
const name = bold(p.displayName);
|
const name = bold(p.displayName);
|
||||||
const meta: string[] = [];
|
const meta: string[] = [];
|
||||||
@@ -201,6 +247,12 @@ export async function runPeers(flags: PeersFlags): Promise<void> {
|
|||||||
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
|
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
|
||||||
const summary = p.summary ? dim(` — ${p.summary}`) : "";
|
const summary = p.summary ? dim(` — ${p.summary}`) : "";
|
||||||
const pubkeyTag = dim(` · ${p.pubkey.slice(0, 16)}…`);
|
const pubkeyTag = dim(` · ${p.pubkey.slice(0, 16)}…`);
|
||||||
|
// Short sessionId tag — appears for sibling sessions of the same
|
||||||
|
// member that would otherwise be visually identical (same name,
|
||||||
|
// same cwd, only the truncated pubkey on the right differs).
|
||||||
|
const sidTag = p.sessionId
|
||||||
|
? dim(` · sid:${p.sessionId.slice(0, 8)}`)
|
||||||
|
: "";
|
||||||
const selfTag = p.isThisSession
|
const selfTag = p.isThisSession
|
||||||
? dim(" ") + yellow("(this session)")
|
? dim(" ") + yellow("(this session)")
|
||||||
: p.isSelf
|
: p.isSelf
|
||||||
@@ -224,7 +276,7 @@ export async function runPeers(flags: PeersFlags): Promise<void> {
|
|||||||
const tagsStr = inlineTags.length ? " [" + inlineTags.join(", ") + "]" : "";
|
const tagsStr = inlineTags.length ? " [" + inlineTags.join(", ") + "]" : "";
|
||||||
|
|
||||||
render.info(
|
render.info(
|
||||||
`${statusDot} ${name}${selfTag}${tagsStr}${metaStr}${pubkeyTag}${summary}`,
|
`${statusDot} ${name}${selfTag}${tagsStr}${metaStr}${pubkeyTag}${sidTag}${summary}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Second line: cwd + an explicit role/groups footer when both
|
// Second line: cwd + an explicit role/groups footer when both
|
||||||
|
|||||||
@@ -101,12 +101,17 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Self-DM safety check: if target is a 64-char hex that matches the
|
// Self-DM safety check: if target is a 64-char hex that matches the
|
||||||
// caller's own member pubkey (or any of the caller's session/member
|
// caller's own member pubkey, refuse without --self. Catches the
|
||||||
// entries), refuse without --self. Catches the common pasted-from-
|
// common pasted-from-peer-list-not-realizing-it-was-mine footgun.
|
||||||
// peer-list-not-realizing-it-was-mine footgun.
|
// With --self, member-pubkey targeting fans out to every connected
|
||||||
if (!flags.self && meshSlug) {
|
// sibling session of your member (the broker's drain only matches
|
||||||
|
// exact session pubkeys, so we resolve here in the CLI).
|
||||||
|
if (meshSlug) {
|
||||||
const joined = config.meshes.find((m) => m.slug === meshSlug);
|
const joined = config.meshes.find((m) => m.slug === meshSlug);
|
||||||
if (joined && /^[0-9a-f]{64}$/i.test(to) && to.toLowerCase() === joined.pubkey.toLowerCase()) {
|
const isOwnMemberKey =
|
||||||
|
joined && /^[0-9a-f]{64}$/i.test(to) && to.toLowerCase() === joined.pubkey.toLowerCase();
|
||||||
|
|
||||||
|
if (isOwnMemberKey && !flags.self) {
|
||||||
render.err(
|
render.err(
|
||||||
`Target "${to.slice(0, 16)}…" is your own member pubkey on mesh "${meshSlug}".`,
|
`Target "${to.slice(0, 16)}…" is your own member pubkey on mesh "${meshSlug}".`,
|
||||||
);
|
);
|
||||||
@@ -115,6 +120,68 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
|
|||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isOwnMemberKey && flags.self) {
|
||||||
|
// Member-pubkey fan-out: resolve to every connected sibling
|
||||||
|
// session pubkey and send one message per recipient. Required
|
||||||
|
// because the broker's drain query at apps/broker/src/broker.ts
|
||||||
|
// matches target_spec only against full session pubkeys —
|
||||||
|
// sending to a member pubkey would queue successfully but no
|
||||||
|
// drain would fetch.
|
||||||
|
try {
|
||||||
|
const { tryListPeersViaDaemon } = await import("~/services/bridge/daemon-route.js");
|
||||||
|
const { getSessionInfo } = await import("~/services/session/resolve.js");
|
||||||
|
const peers = (await tryListPeersViaDaemon()) ?? [];
|
||||||
|
const session = await getSessionInfo();
|
||||||
|
const ownSessionPk = session?.presence?.sessionPubkey?.toLowerCase();
|
||||||
|
const siblings = peers.filter((p) => {
|
||||||
|
const r = p as { memberPubkey?: string; pubkey?: string; channel?: string };
|
||||||
|
if (!r.pubkey) return false;
|
||||||
|
if (ownSessionPk && r.pubkey.toLowerCase() === ownSessionPk) return false;
|
||||||
|
if (r.channel === "claudemesh-daemon") return false;
|
||||||
|
return r.memberPubkey?.toLowerCase() === to.toLowerCase();
|
||||||
|
});
|
||||||
|
if (siblings.length === 0) {
|
||||||
|
render.err(`--self fan-out: no other sibling sessions of your member online.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const results: Array<{ pubkey: string; ok: boolean; messageId?: string; error?: string }> = [];
|
||||||
|
for (const peer of siblings) {
|
||||||
|
const pk = (peer as { pubkey: string }).pubkey;
|
||||||
|
const dr = await trySendViaDaemon({ to: pk, message, priority, expectedMesh: meshSlug ?? undefined });
|
||||||
|
if (dr === null) {
|
||||||
|
results.push({ pubkey: pk, ok: false, error: "daemon path unavailable" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (dr.ok) {
|
||||||
|
results.push({
|
||||||
|
pubkey: pk,
|
||||||
|
ok: true,
|
||||||
|
...(dr.messageId ? { messageId: dr.messageId } : {}),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
results.push({ pubkey: pk, ok: false, error: dr.error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const okCount = results.filter((r) => r.ok).length;
|
||||||
|
if (flags.json) {
|
||||||
|
console.log(JSON.stringify({ ok: okCount > 0, fanout: results, via: "daemon" }));
|
||||||
|
} else if (okCount === results.length) {
|
||||||
|
render.ok(`fanned out to ${okCount} sibling session${okCount === 1 ? "" : "s"} (daemon)`);
|
||||||
|
for (const r of results) render.info(dim(` → ${r.pubkey.slice(0, 16)}… ${r.messageId ? dim(r.messageId.slice(0, 8)) : ""}`));
|
||||||
|
} else {
|
||||||
|
render.warn(`fanned out: ${okCount}/${results.length} delivered`);
|
||||||
|
for (const r of results) {
|
||||||
|
const tag = r.ok ? "✔" : "✘";
|
||||||
|
render.info(` ${tag} ${r.pubkey.slice(0, 16)}… ${r.error ? dim(`— ${r.error}`) : ""}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
render.err(`--self fan-out failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Daemon path — preferred when a long-lived daemon is local. UDS at
|
// Daemon path — preferred when a long-lived daemon is local. UDS at
|
||||||
|
|||||||
@@ -1,25 +1,51 @@
|
|||||||
import { whoAmI } from "~/services/auth/facade.js";
|
import { whoAmI } from "~/services/auth/facade.js";
|
||||||
|
import { getSessionInfo } from "~/services/session/resolve.js";
|
||||||
import { render } from "~/ui/render.js";
|
import { render } from "~/ui/render.js";
|
||||||
import { bold, clay, dim } from "~/ui/styles.js";
|
import { bold, clay, dim, yellow } from "~/ui/styles.js";
|
||||||
import { EXIT } from "~/constants/exit-codes.js";
|
import { EXIT } from "~/constants/exit-codes.js";
|
||||||
|
|
||||||
export async function whoami(opts: { json?: boolean }): Promise<number> {
|
export async function whoami(opts: { json?: boolean }): Promise<number> {
|
||||||
const result = await whoAmI();
|
const result = await whoAmI();
|
||||||
|
// 1.32.0+: surface the calling session's identity when whoami is run
|
||||||
|
// from inside a `claudemesh launch`-spawned shell. Previously the
|
||||||
|
// command only reported web sign-in + local mesh memberships, and a
|
||||||
|
// launched session had to dig env vars + parse config.json to figure
|
||||||
|
// out its own session pubkey.
|
||||||
|
const session = await getSessionInfo();
|
||||||
|
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
console.log(JSON.stringify({ schema_version: "1.0", ...result }, null, 2));
|
console.log(JSON.stringify({ schema_version: "1.0", ...result, session }, null, 2));
|
||||||
return result.signed_in || result.local ? EXIT.SUCCESS : EXIT.AUTH_FAILED;
|
return result.signed_in || result.local || session ? EXIT.SUCCESS : EXIT.AUTH_FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show whatever we have. Both the web session and the local mesh
|
// Show whatever we have. Web session, local mesh config, and the
|
||||||
// config are independent surfaces of identity; suppress sections that
|
// launched-session identity are three independent surfaces.
|
||||||
// are empty.
|
if (!result.signed_in && !result.local && !session) {
|
||||||
if (!result.signed_in && !result.local) {
|
|
||||||
render.err("Not signed in", "Run `claudemesh login` to sign in or `claudemesh <invite>` to join.");
|
render.err("Not signed in", "Run `claudemesh login` to sign in or `claudemesh <invite>` to join.");
|
||||||
return EXIT.AUTH_FAILED;
|
return EXIT.AUTH_FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
render.section("whoami");
|
render.section("whoami");
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
const sessionPk = session.presence?.sessionPubkey;
|
||||||
|
const groups = (session.groups ?? []).join(", ") || dim("(none)");
|
||||||
|
render.kv([
|
||||||
|
["this session", `${yellow(session.displayName)} on ${bold(session.mesh)}`],
|
||||||
|
["session id", dim(session.sessionId)],
|
||||||
|
...(sessionPk
|
||||||
|
? [["session pubkey", dim(`${sessionPk.slice(0, 16)}… (full: ${sessionPk})`)] as [string, string]]
|
||||||
|
: []),
|
||||||
|
...(session.role
|
||||||
|
? [["role", session.role] as [string, string]]
|
||||||
|
: []),
|
||||||
|
["groups", groups],
|
||||||
|
...(session.cwd ? [["cwd", dim(session.cwd)] as [string, string]] : []),
|
||||||
|
["pid", String(session.pid)],
|
||||||
|
]);
|
||||||
|
render.blank();
|
||||||
|
}
|
||||||
|
|
||||||
if (result.signed_in) {
|
if (result.signed_in) {
|
||||||
render.kv([
|
render.kv([
|
||||||
["user", `${bold(result.user!.display_name)} ${dim(`(${result.user!.email})`)}`],
|
["user", `${bold(result.user!.display_name)} ${dim(`(${result.user!.email})`)}`],
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ Peer (resource form, recommended)
|
|||||||
|
|
||||||
Message (resource form)
|
Message (resource form)
|
||||||
claudemesh message send <to> <m> send a message (alias: send)
|
claudemesh message send <to> <m> send a message (alias: send)
|
||||||
|
flags: [--priority now|next|low] [--mesh <slug>]
|
||||||
|
[--self] (allow targeting your own member/session pubkey;
|
||||||
|
fans out to every sibling session of your member)
|
||||||
|
[--json] (machine-readable result)
|
||||||
claudemesh message inbox drain pending (alias: inbox)
|
claudemesh message inbox drain pending (alias: inbox)
|
||||||
claudemesh message status <id> delivery status (alias: msg-status)
|
claudemesh message status <id> delivery status (alias: msg-status)
|
||||||
|
|
||||||
@@ -388,7 +392,7 @@ async function main(): Promise<void> {
|
|||||||
case "bans": { const { runBans } = await import("~/commands/ban.js"); process.exit(await runBans({ mesh: flags.mesh as string, json: !!flags.json })); break; }
|
case "bans": { const { runBans } = await import("~/commands/ban.js"); process.exit(await runBans({ mesh: flags.mesh as string, json: !!flags.json })); break; }
|
||||||
|
|
||||||
// Messaging
|
// Messaging
|
||||||
case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: flags.json as boolean | string | undefined }); break; }
|
case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: flags.json as boolean | string | undefined, all: !!flags.all }); break; }
|
||||||
case "send": { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json, self: !!flags.self }, positionals[0] ?? "", positionals.slice(1).join(" ")); break; }
|
case "send": { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json, self: !!flags.self }, positionals[0] ?? "", positionals.slice(1).join(" ")); break; }
|
||||||
case "inbox": { const { runInbox } = await import("~/commands/inbox.js"); await runInbox({ json: !!flags.json }); break; }
|
case "inbox": { const { runInbox } = await import("~/commands/inbox.js"); await runInbox({ json: !!flags.json }); break; }
|
||||||
case "state": {
|
case "state": {
|
||||||
@@ -510,7 +514,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
case "peer": {
|
case "peer": {
|
||||||
const sub = positionals[0];
|
const sub = positionals[0];
|
||||||
const f = { mesh: flags.mesh as string, json: flags.json as boolean | string | undefined };
|
const f = { mesh: flags.mesh as string, json: flags.json as boolean | string | undefined, all: !!flags.all };
|
||||||
const id = positionals[1] ?? "";
|
const id = positionals[1] ?? "";
|
||||||
if (sub === "list") { const { runPeers } = await import("~/commands/peers.js"); await runPeers(f); }
|
if (sub === "list") { const { runPeers } = await import("~/commands/peers.js"); await runPeers(f); }
|
||||||
else if (sub === "kick") { const { runKick } = await import("~/commands/kick.js"); process.exit(await runKick(id, { mesh: flags.mesh as string, stale: flags.stale as string, all: !!flags.all })); }
|
else if (sub === "kick") { const { runKick } = await import("~/commands/kick.js"); process.exit(await runKick(id, { mesh: flags.mesh as string, stale: flags.stale as string, all: !!flags.all })); }
|
||||||
@@ -525,7 +529,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
case "message": {
|
case "message": {
|
||||||
const sub = positionals[0];
|
const sub = positionals[0];
|
||||||
if (sub === "send") { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json }, positionals[1] ?? "", positionals.slice(2).join(" ")); }
|
if (sub === "send") { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json, self: !!flags.self }, positionals[1] ?? "", positionals.slice(2).join(" ")); }
|
||||||
else if (sub === "inbox") { const { runInbox } = await import("~/commands/inbox.js"); await runInbox({ json: !!flags.json }); }
|
else if (sub === "inbox") { const { runInbox } = await import("~/commands/inbox.js"); await runInbox({ json: !!flags.json }); }
|
||||||
else if (sub === "status") { const { runMsgStatus } = await import("~/commands/broker-actions.js"); process.exit(await runMsgStatus(positionals[1], { mesh: flags.mesh as string, json: !!flags.json })); }
|
else if (sub === "status") { const { runMsgStatus } = await import("~/commands/broker-actions.js"); process.exit(await runMsgStatus(positionals[1], { mesh: flags.mesh as string, json: !!flags.json })); }
|
||||||
else { console.error("Usage: claudemesh message <send|inbox|status>"); process.exit(EXIT.INVALID_ARGS); }
|
else { console.error("Usage: claudemesh message <send|inbox|status>"); process.exit(EXIT.INVALID_ARGS); }
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ export interface ResolvedSession {
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
groups?: string[];
|
groups?: string[];
|
||||||
|
/** 1.32.0+: per-launch presence material lifted from the daemon's
|
||||||
|
* registry so callers (peer list, whoami) can identify themselves
|
||||||
|
* in the broker's peer list without re-handshaking a fresh WS. */
|
||||||
|
presence?: {
|
||||||
|
sessionPubkey: string;
|
||||||
|
sessionSecretKey: string;
|
||||||
|
parentAttestation?: unknown;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let cached: ResolvedSession | null | undefined = undefined;
|
let cached: ResolvedSession | null | undefined = undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user