feat: scheduled messages — schedule_reminder, send_later, list_scheduled, cancel_scheduled
- Broker: schedule/list_scheduled/cancel_scheduled WS message types + in-memory delivery - Client: scheduleMessage(), listScheduled(), cancelScheduled() with resolver Map pattern - MCP: schedule_reminder, send_later, list_scheduled, cancel_scheduled tools - CLI: claudemesh remind <msg> --in 2h | --at 15:00 | list | cancel <id> - Types: WSScheduleMessage, WSScheduledAckMessage, WSScheduledListMessage, WSCancelScheduledAckMessage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudemesh-cli",
|
||||
"version": "0.6.6",
|
||||
"version": "0.6.5",
|
||||
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
|
||||
59
apps/cli/src/commands/connect.ts
Normal file
59
apps/cli/src/commands/connect.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Short-lived WS connection helper for CLI commands (peers, send, inbox, state).
|
||||
*
|
||||
* Opens a connection to one mesh, runs a callback, then closes cleanly.
|
||||
* The caller never deals with connect/close lifecycle.
|
||||
*/
|
||||
|
||||
import { hostname } from "node:os";
|
||||
import { BrokerClient } from "../ws/client";
|
||||
import { loadConfig } from "../state/config";
|
||||
import type { JoinedMesh } from "../state/config";
|
||||
|
||||
export interface ConnectOpts {
|
||||
/** Mesh slug to connect to. Auto-selects if only one mesh joined. */
|
||||
meshSlug?: string | null;
|
||||
/** Display name for this session. Defaults to hostname-pid. */
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export async function withMesh<T>(
|
||||
opts: ConnectOpts,
|
||||
fn: (client: BrokerClient, mesh: JoinedMesh) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const config = loadConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run `claudemesh join <url>` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let mesh: JoinedMesh;
|
||||
if (opts.meshSlug) {
|
||||
const found = config.meshes.find((m) => m.slug === opts.meshSlug);
|
||||
if (!found) {
|
||||
console.error(
|
||||
`Mesh "${opts.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
mesh = found;
|
||||
} else if (config.meshes.length === 1) {
|
||||
mesh = config.meshes[0]!;
|
||||
} else {
|
||||
console.error(
|
||||
`Multiple meshes joined. Specify one with --mesh <slug>.\nJoined: ${config.meshes.map((m) => m.slug).join(", ")}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const displayName = opts.displayName ?? config.displayName ?? `${hostname()}-${process.pid}`;
|
||||
const client = new BrokerClient(mesh, { displayName });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await fn(client, mesh);
|
||||
return result;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
60
apps/cli/src/commands/inbox.ts
Normal file
60
apps/cli/src/commands/inbox.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* `claudemesh inbox` — read pending peer messages.
|
||||
*
|
||||
* Connects, waits briefly for push delivery, drains the buffer, prints.
|
||||
* Works best when message-mode is "inbox" or "off" (messages held at broker).
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import type { InboundPush } from "../ws/client";
|
||||
|
||||
export interface InboxFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
wait?: number;
|
||||
}
|
||||
|
||||
function formatMessage(msg: InboundPush, useColor: boolean): string {
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
const text = msg.plaintext ?? `[encrypted: ${msg.ciphertext.slice(0, 32)}…]`;
|
||||
const from = msg.senderPubkey.slice(0, 8);
|
||||
const time = new Date(msg.createdAt).toLocaleTimeString();
|
||||
const kindTag = msg.kind === "direct" ? "→ direct" : msg.kind;
|
||||
|
||||
return ` ${bold(from)} ${dim(`[${kindTag}] ${time}`)}\n ${text}`;
|
||||
}
|
||||
|
||||
export async function runInbox(flags: InboxFlags): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
const waitMs = (flags.wait ?? 1) * 1000;
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
// Wait briefly for broker to push any held messages.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
|
||||
|
||||
const messages = client.drainPushBuffer();
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(messages, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
console.log(dim(`No messages on mesh "${mesh.slug}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(bold(`Inbox — ${mesh.slug}`) + dim(` (${messages.length} message${messages.length === 1 ? "" : "s"})`));
|
||||
console.log("");
|
||||
for (const msg of messages) {
|
||||
console.log(formatMessage(msg, useColor));
|
||||
console.log("");
|
||||
}
|
||||
});
|
||||
}
|
||||
58
apps/cli/src/commands/info.ts
Normal file
58
apps/cli/src/commands/info.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* `claudemesh info` — show mesh overview: slug, broker URL, peer count, state count.
|
||||
*
|
||||
* Useful for AI agents to orient themselves in a mesh via bash.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import { loadConfig } from "../state/config";
|
||||
|
||||
export interface InfoFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runInfo(flags: InfoFlags): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const [brokerInfo, peers, state] = await Promise.all([
|
||||
client.meshInfo(),
|
||||
client.listPeers(),
|
||||
client.listState(),
|
||||
]);
|
||||
|
||||
const output = {
|
||||
slug: mesh.slug,
|
||||
meshId: mesh.meshId,
|
||||
memberId: mesh.memberId,
|
||||
brokerUrl: mesh.brokerUrl,
|
||||
displayName: config.displayName ?? null,
|
||||
peerCount: peers.length,
|
||||
stateCount: state.length,
|
||||
...(brokerInfo ?? {}),
|
||||
};
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(bold(mesh.slug) + dim(` · ${mesh.brokerUrl}`));
|
||||
console.log(dim(` mesh: ${mesh.meshId}`));
|
||||
console.log(dim(` member: ${mesh.memberId}`));
|
||||
console.log(` peers: ${peers.length} connected`);
|
||||
console.log(` state: ${state.length} keys`);
|
||||
if (brokerInfo && typeof brokerInfo === "object") {
|
||||
for (const [k, v] of Object.entries(brokerInfo)) {
|
||||
if (["slug", "meshId", "brokerUrl"].includes(k)) continue;
|
||||
console.log(dim(` ${k}: ${JSON.stringify(v)}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -212,6 +212,88 @@ function writeClaudeSettings(obj: Record<string, unknown>): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* All claudemesh MCP tool names, prefixed for allowedTools.
|
||||
* These let Claude Code use claudemesh tools without --dangerously-skip-permissions.
|
||||
*/
|
||||
const CLAUDEMESH_TOOLS = [
|
||||
"mcp__claudemesh__send_message",
|
||||
"mcp__claudemesh__list_peers",
|
||||
"mcp__claudemesh__check_messages",
|
||||
"mcp__claudemesh__set_summary",
|
||||
"mcp__claudemesh__set_status",
|
||||
"mcp__claudemesh__join_group",
|
||||
"mcp__claudemesh__leave_group",
|
||||
"mcp__claudemesh__get_state",
|
||||
"mcp__claudemesh__set_state",
|
||||
"mcp__claudemesh__list_state",
|
||||
"mcp__claudemesh__remember",
|
||||
"mcp__claudemesh__recall",
|
||||
"mcp__claudemesh__forget",
|
||||
"mcp__claudemesh__share_file",
|
||||
"mcp__claudemesh__get_file",
|
||||
"mcp__claudemesh__list_files",
|
||||
"mcp__claudemesh__file_status",
|
||||
"mcp__claudemesh__delete_file",
|
||||
"mcp__claudemesh__vector_store",
|
||||
"mcp__claudemesh__vector_search",
|
||||
"mcp__claudemesh__vector_delete",
|
||||
"mcp__claudemesh__list_collections",
|
||||
"mcp__claudemesh__graph_query",
|
||||
"mcp__claudemesh__graph_execute",
|
||||
"mcp__claudemesh__mesh_info",
|
||||
"mcp__claudemesh__ping_mesh",
|
||||
"mcp__claudemesh__message_status",
|
||||
"mcp__claudemesh__share_context",
|
||||
"mcp__claudemesh__get_context",
|
||||
"mcp__claudemesh__list_contexts",
|
||||
"mcp__claudemesh__create_task",
|
||||
"mcp__claudemesh__claim_task",
|
||||
"mcp__claudemesh__complete_task",
|
||||
"mcp__claudemesh__list_tasks",
|
||||
"mcp__claudemesh__create_stream",
|
||||
"mcp__claudemesh__publish",
|
||||
"mcp__claudemesh__subscribe",
|
||||
"mcp__claudemesh__list_streams",
|
||||
"mcp__claudemesh__mesh_execute",
|
||||
"mcp__claudemesh__mesh_query",
|
||||
"mcp__claudemesh__mesh_schema",
|
||||
];
|
||||
|
||||
/**
|
||||
* Pre-approve all claudemesh MCP tools in allowedTools.
|
||||
* Merges into any existing list — never overwrites other entries.
|
||||
* Returns which tools were added vs already present.
|
||||
*/
|
||||
function installAllowedTools(): { added: string[]; unchanged: number } {
|
||||
const settings = readClaudeSettings();
|
||||
const existing = new Set<string>((settings.allowedTools as string[] | undefined) ?? []);
|
||||
const toAdd = CLAUDEMESH_TOOLS.filter((t) => !existing.has(t));
|
||||
if (toAdd.length > 0) {
|
||||
settings.allowedTools = [...Array.from(existing), ...toAdd];
|
||||
writeClaudeSettings(settings);
|
||||
}
|
||||
return { added: toAdd, unchanged: CLAUDEMESH_TOOLS.length - toAdd.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove claudemesh tools from allowedTools.
|
||||
* Leaves all other entries intact. Returns count removed.
|
||||
*/
|
||||
function uninstallAllowedTools(): number {
|
||||
if (!existsSync(CLAUDE_SETTINGS)) return 0;
|
||||
const settings = readClaudeSettings();
|
||||
const existing = (settings.allowedTools as string[] | undefined) ?? [];
|
||||
const toolSet = new Set(CLAUDEMESH_TOOLS);
|
||||
const kept = existing.filter((t) => !toolSet.has(t));
|
||||
const removed = existing.length - kept.length;
|
||||
if (removed > 0) {
|
||||
settings.allowedTools = kept;
|
||||
writeClaudeSettings(settings);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a Stop + UserPromptSubmit hook entry to ~/.claude/settings.json,
|
||||
* idempotent on the command string. Returns counts for reporting.
|
||||
@@ -321,6 +403,26 @@ export function runInstall(args: string[] = []): void {
|
||||
),
|
||||
);
|
||||
|
||||
// allowedTools — pre-approve claudemesh MCP tools so peers don't need
|
||||
// --dangerously-skip-permissions just to call mesh tools.
|
||||
try {
|
||||
const { added, unchanged } = installAllowedTools();
|
||||
if (added.length > 0) {
|
||||
console.log(
|
||||
`✓ allowedTools: ${added.length} claudemesh tools pre-approved${unchanged > 0 ? `, ${unchanged} already present` : ""}`,
|
||||
);
|
||||
console.log(dim(` This lets claudemesh tools run without --dangerously-skip-permissions.`));
|
||||
console.log(dim(` Your existing allowedTools entries were preserved.`));
|
||||
} else {
|
||||
console.log(`✓ allowedTools: all ${unchanged} claudemesh tools already pre-approved`);
|
||||
}
|
||||
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`⚠ allowedTools update failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status).
|
||||
if (!skipHooks) {
|
||||
try {
|
||||
@@ -375,6 +477,20 @@ export function runUninstall(): void {
|
||||
console.log(`· MCP server "${MCP_NAME}" not present`);
|
||||
}
|
||||
|
||||
// allowedTools
|
||||
try {
|
||||
const removed = uninstallAllowedTools();
|
||||
if (removed > 0) {
|
||||
console.log(`✓ allowedTools: ${removed} claudemesh tools removed`);
|
||||
} else {
|
||||
console.log("· No claudemesh allowedTools to remove");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`⚠ allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Hooks
|
||||
try {
|
||||
const removed = uninstallHooks();
|
||||
|
||||
@@ -98,12 +98,12 @@ async function confirmPermissions(): Promise<void> {
|
||||
|
||||
console.log(yellow(bold(" Autonomous mode")));
|
||||
console.log("");
|
||||
console.log(" Claude will send and receive peer messages without asking");
|
||||
console.log(" you first. Peers exchange text only — no file access,");
|
||||
console.log(" no tool calls, no code execution.");
|
||||
console.log(" Claude will run with --dangerously-skip-permissions, bypassing");
|
||||
console.log(" ALL permission prompts — not just claudemesh tools.");
|
||||
console.log(" Peers exchange text only — no file access, no tool calls.");
|
||||
console.log("");
|
||||
console.log(dim(" Same as: claude --dangerously-skip-permissions"));
|
||||
console.log(dim(" Skip this prompt: claudemesh launch -y"));
|
||||
console.log(dim(" Without -y: only claudemesh tools are pre-approved (via allowedTools)."));
|
||||
console.log(dim(" Use -y for autonomous agents. Omit it for shared/multi-person meshes."));
|
||||
console.log("");
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
@@ -313,10 +313,14 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
||||
}
|
||||
filtered.push(args.claudeArgs[i]!);
|
||||
}
|
||||
// --dangerously-skip-permissions is only added when the user explicitly
|
||||
// passes -y / --yes. Without it, claudemesh tools still work because
|
||||
// `claudemesh install` pre-approves them via allowedTools in settings.json.
|
||||
// This keeps permissions tight for multi-person meshes.
|
||||
const claudeArgs = [
|
||||
"--dangerously-load-development-channels",
|
||||
"server:claudemesh",
|
||||
"--dangerously-skip-permissions",
|
||||
...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []),
|
||||
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
|
||||
...filtered,
|
||||
];
|
||||
|
||||
63
apps/cli/src/commands/memory.ts
Normal file
63
apps/cli/src/commands/memory.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* `claudemesh remember <text> [--tags tag1,tag2]` — store a memory in the mesh.
|
||||
* `claudemesh recall <query>` — search mesh memory.
|
||||
*
|
||||
* Useful for AI agents using bash when the MCP server isn't active.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface MemoryFlags {
|
||||
mesh?: string;
|
||||
tags?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runRemember(flags: MemoryFlags, content: string): Promise<void> {
|
||||
const tags = flags.tags
|
||||
? flags.tags.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const id = await client.remember(content, tags);
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ id, content, tags }));
|
||||
return;
|
||||
}
|
||||
if (id) {
|
||||
console.log(`✓ Remembered (${id.slice(0, 8)})`);
|
||||
} else {
|
||||
console.error("✗ Failed to store memory");
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function runRecall(flags: MemoryFlags, query: string): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const memories = await client.recall(query);
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(memories, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (memories.length === 0) {
|
||||
console.log(dim("No memories found."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const m of memories) {
|
||||
const tags = m.tags.length ? dim(` [${m.tags.join(", ")}]`) : "";
|
||||
console.log(`${bold(m.id.slice(0, 8))}${tags}`);
|
||||
console.log(` ${m.content}`);
|
||||
console.log(dim(` ${m.rememberedBy} · ${new Date(m.rememberedAt).toLocaleString()}`));
|
||||
console.log("");
|
||||
}
|
||||
});
|
||||
}
|
||||
48
apps/cli/src/commands/peers.ts
Normal file
48
apps/cli/src/commands/peers.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* `claudemesh peers` — list connected peers in the mesh.
|
||||
*
|
||||
* Connects, fetches the peer list, prints it, disconnects.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface PeersFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runPeers(flags: PeersFlags): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
const green = (s: string) => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
const yellow = (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const peers = await client.listPeers();
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(peers, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (peers.length === 0) {
|
||||
console.log(dim(`No peers connected on mesh "${mesh.slug}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(bold(`Peers on ${mesh.slug}`) + dim(` (${peers.length})`));
|
||||
console.log("");
|
||||
for (const p of peers) {
|
||||
const groups = p.groups.length
|
||||
? " [" + p.groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
|
||||
: "";
|
||||
const statusIcon = p.status === "working" ? yellow("●") : green("●");
|
||||
const name = bold(p.displayName);
|
||||
const summary = p.summary ? dim(` ${p.summary}`) : "";
|
||||
console.log(` ${statusIcon} ${name}${groups}${summary}`);
|
||||
}
|
||||
console.log("");
|
||||
});
|
||||
}
|
||||
134
apps/cli/src/commands/remind.ts
Normal file
134
apps/cli/src/commands/remind.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* `claudemesh remind <message> --in <duration> | --at <time>`
|
||||
* `claudemesh remind list`
|
||||
* `claudemesh remind cancel <id>`
|
||||
*
|
||||
* Human-facing interface to the broker's scheduled message delivery.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface RemindFlags {
|
||||
mesh?: string;
|
||||
in?: string; // e.g. "2h", "30m", "90s"
|
||||
at?: string; // ISO or HH:MM
|
||||
to?: string; // default: self
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
function parseDuration(raw: string): number | null {
|
||||
const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|d|day)?$/i);
|
||||
if (!m) return null;
|
||||
const n = parseFloat(m[1]!);
|
||||
const unit = (m[2] ?? "s").toLowerCase();
|
||||
if (unit.startsWith("d")) return n * 86_400_000;
|
||||
if (unit.startsWith("h")) return n * 3_600_000;
|
||||
if (unit.startsWith("m")) return n * 60_000;
|
||||
return n * 1_000;
|
||||
}
|
||||
|
||||
function parseDeliverAt(flags: RemindFlags): number | null {
|
||||
if (flags.in) {
|
||||
const ms = parseDuration(flags.in);
|
||||
if (ms === null) return null;
|
||||
return Date.now() + ms;
|
||||
}
|
||||
if (flags.at) {
|
||||
// Try HH:MM first
|
||||
const hm = flags.at.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (hm) {
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
target.setHours(parseInt(hm[1]!, 10), parseInt(hm[2]!, 10), 0, 0);
|
||||
if (target <= now) target.setDate(target.getDate() + 1); // next occurrence
|
||||
return target.getTime();
|
||||
}
|
||||
const ts = Date.parse(flags.at);
|
||||
return isNaN(ts) ? null : ts;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function runRemind(
|
||||
flags: RemindFlags,
|
||||
positional: string[],
|
||||
): Promise<void> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
const action = positional[0];
|
||||
|
||||
// claudemesh remind list
|
||||
if (action === "list") {
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const scheduled = await client.listScheduled();
|
||||
if (flags.json) { console.log(JSON.stringify(scheduled, null, 2)); return; }
|
||||
if (scheduled.length === 0) { console.log(dim("No pending reminders.")); return; }
|
||||
for (const m of scheduled) {
|
||||
const when = new Date(m.deliverAt).toLocaleString();
|
||||
const to = m.to === client.getSessionPubkey() ? dim("(self)") : m.to;
|
||||
console.log(` ${bold(m.id.slice(0, 8))} → ${to} at ${when}`);
|
||||
console.log(` ${dim(m.message.slice(0, 80))}`);
|
||||
console.log("");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// claudemesh remind cancel <id>
|
||||
if (action === "cancel") {
|
||||
const id = positional[1];
|
||||
if (!id) { console.error("Usage: claudemesh remind cancel <id>"); process.exit(1); }
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const ok = await client.cancelScheduled(id);
|
||||
if (ok) console.log(`✓ Cancelled ${id}`);
|
||||
else { console.error(`✗ Not found or already fired: ${id}`); process.exit(1); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// claudemesh remind <message> --in <duration> | --at <time>
|
||||
const message = action ?? positional.join(" ");
|
||||
if (!message) {
|
||||
console.error("Usage: claudemesh remind <message> --in <duration>");
|
||||
console.error(" claudemesh remind <message> --at <time>");
|
||||
console.error(" claudemesh remind list");
|
||||
console.error(" claudemesh remind cancel <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const deliverAt = parseDeliverAt(flags);
|
||||
if (deliverAt === null) {
|
||||
console.error('Specify when: --in <duration> (e.g. "2h", "30m") or --at <time> (e.g. "15:00")');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
// Determine target: --to flag or self
|
||||
let targetSpec: string;
|
||||
if (flags.to && flags.to !== "self") {
|
||||
if (flags.to.startsWith("@") || flags.to === "*" || /^[0-9a-f]{64}$/i.test(flags.to)) {
|
||||
targetSpec = flags.to;
|
||||
} else {
|
||||
const peers = await client.listPeers();
|
||||
const match = peers.find((p) => p.displayName.toLowerCase() === flags.to!.toLowerCase());
|
||||
if (!match) {
|
||||
console.error(`Peer "${flags.to}" not found. Online: ${peers.map((p) => p.displayName).join(", ") || "(none)"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
targetSpec = match.pubkey;
|
||||
}
|
||||
} else {
|
||||
targetSpec = client.getSessionPubkey() ?? "*";
|
||||
}
|
||||
|
||||
const result = await client.scheduleMessage(targetSpec, message, deliverAt);
|
||||
if (!result) { console.error("✗ Broker did not acknowledge — check connection"); process.exit(1); }
|
||||
|
||||
if (flags.json) { console.log(JSON.stringify(result)); return; }
|
||||
const when = new Date(result.deliverAt).toLocaleString();
|
||||
const toLabel = !flags.to || flags.to === "self" ? "yourself" : flags.to;
|
||||
console.log(`✓ Reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} at ${when}`);
|
||||
});
|
||||
}
|
||||
51
apps/cli/src/commands/send.ts
Normal file
51
apps/cli/src/commands/send.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* `claudemesh send <to> <message>` — send a message to a peer or group.
|
||||
*
|
||||
* <to> can be:
|
||||
* - a display name ("Mou")
|
||||
* - a pubkey hex ("abc123...")
|
||||
* - @group ("@flexicar")
|
||||
* - * (broadcast to all)
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import type { Priority } from "../ws/client";
|
||||
|
||||
export interface SendFlags {
|
||||
mesh?: string;
|
||||
priority?: string;
|
||||
}
|
||||
|
||||
export async function runSend(flags: SendFlags, to: string, message: string): Promise<void> {
|
||||
const priority: Priority =
|
||||
flags.priority === "now" ? "now"
|
||||
: flags.priority === "low" ? "low"
|
||||
: "next";
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
// Resolve display name → pubkey for direct messages.
|
||||
// If `to` starts with @, *, or looks like a hex pubkey, use as-is.
|
||||
let targetSpec = to;
|
||||
if (!to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) {
|
||||
// Treat as display name — look up pubkey via list_peers.
|
||||
const peers = await client.listPeers();
|
||||
const match = peers.find(
|
||||
(p) => p.displayName.toLowerCase() === to.toLowerCase(),
|
||||
);
|
||||
if (!match) {
|
||||
const names = peers.map((p) => p.displayName).join(", ");
|
||||
console.error(`Peer "${to}" not found. Online: ${names || "(none)"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
targetSpec = match.pubkey;
|
||||
}
|
||||
|
||||
const result = await client.send(targetSpec, message, priority);
|
||||
if (result.ok) {
|
||||
console.log(`✓ Sent to ${to}${result.messageId ? ` (${result.messageId.slice(0, 8)})` : ""}`);
|
||||
} else {
|
||||
console.error(`✗ Send failed: ${result.error ?? "unknown error"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
75
apps/cli/src/commands/state.ts
Normal file
75
apps/cli/src/commands/state.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* `claudemesh state get <key>` — read a shared state value
|
||||
* `claudemesh state set <key> <value>` — write a shared state value
|
||||
* `claudemesh state list` — list all state entries
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface StateFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runStateGet(flags: StateFlags, key: string): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const entry = await client.getState(key);
|
||||
if (!entry) {
|
||||
console.log(dim(`(not set)`));
|
||||
return;
|
||||
}
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(entry, null, 2));
|
||||
return;
|
||||
}
|
||||
const val = typeof entry.value === "string" ? entry.value : JSON.stringify(entry.value);
|
||||
console.log(val);
|
||||
console.log(dim(` set by ${entry.updatedBy} at ${new Date(entry.updatedAt).toLocaleString()}`));
|
||||
});
|
||||
}
|
||||
|
||||
export async function runStateSet(flags: StateFlags, key: string, value: string): Promise<void> {
|
||||
// Try to parse as JSON so numbers/booleans/objects work; fall back to string.
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
parsed = value;
|
||||
}
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
await client.setState(key, parsed);
|
||||
console.log(`✓ ${key} = ${JSON.stringify(parsed)}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runStateList(flags: StateFlags): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const entries = await client.listState();
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(entries, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log(dim(`No state on mesh "${mesh.slug}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
const val = typeof e.value === "string" ? e.value : JSON.stringify(e.value);
|
||||
console.log(`${bold(e.key)}: ${val}`);
|
||||
console.log(dim(` ${e.updatedBy} · ${new Date(e.updatedAt).toLocaleString()}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,13 @@ import { runLaunch } from "./commands/launch";
|
||||
import { runStatus } from "./commands/status";
|
||||
import { runDoctor } from "./commands/doctor";
|
||||
import { runWelcome } from "./commands/welcome";
|
||||
import { runPeers } from "./commands/peers";
|
||||
import { runSend } from "./commands/send";
|
||||
import { runInbox } from "./commands/inbox";
|
||||
import { runStateGet, runStateSet, runStateList } from "./commands/state";
|
||||
import { runRemember, runRecall } from "./commands/memory";
|
||||
import { runInfo } from "./commands/info";
|
||||
import { runRemind } from "./commands/remind";
|
||||
import { VERSION } from "./version";
|
||||
|
||||
const launch = defineCommand({
|
||||
@@ -145,6 +152,103 @@ const main = defineCommand({
|
||||
run() { runList(); },
|
||||
}),
|
||||
leave,
|
||||
peers: defineCommand({
|
||||
meta: { name: "peers", description: "List connected peers in the mesh" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runPeers(args); },
|
||||
}),
|
||||
send: defineCommand({
|
||||
meta: { name: "send", description: "Send a message to a peer, group, or broadcast" },
|
||||
args: {
|
||||
to: { type: "positional", description: "Recipient: display name, @group, pubkey, or *", required: true },
|
||||
message: { type: "positional", description: "Message text", required: true },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
priority: { type: "string", description: "now | next (default) | low" },
|
||||
},
|
||||
async run({ args }) { await runSend(args, args.to, args.message); },
|
||||
}),
|
||||
inbox: defineCommand({
|
||||
meta: { name: "inbox", description: "Read pending peer messages" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
wait: { type: "string", description: "Seconds to wait for broker delivery (default: 1)" },
|
||||
},
|
||||
async run({ args }) {
|
||||
await runInbox({ ...args, wait: args.wait ? parseInt(args.wait, 10) : undefined });
|
||||
},
|
||||
}),
|
||||
state: defineCommand({
|
||||
meta: { name: "state", description: "Read or write shared mesh state" },
|
||||
args: {
|
||||
action: { type: "positional", description: "get | set | list", required: true },
|
||||
key: { type: "positional", description: "State key (required for get/set)" },
|
||||
value: { type: "positional", description: "Value to set (required for set)" },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) {
|
||||
if (args.action === "list") {
|
||||
await runStateList(args);
|
||||
} else if (args.action === "get") {
|
||||
if (!args.key) { console.error("Usage: claudemesh state get <key>"); process.exit(1); }
|
||||
await runStateGet(args, args.key);
|
||||
} else if (args.action === "set") {
|
||||
if (!args.key || !args.value) { console.error("Usage: claudemesh state set <key> <value>"); process.exit(1); }
|
||||
await runStateSet(args, args.key, args.value);
|
||||
} else {
|
||||
console.error(`Unknown action "${args.action}". Use: get, set, list`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
}),
|
||||
info: defineCommand({
|
||||
meta: { name: "info", description: "Show mesh overview: slug, broker, peer count, state keys" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runInfo(args); },
|
||||
}),
|
||||
remember: defineCommand({
|
||||
meta: { name: "remember", description: "Store a memory in the mesh (accessible to all peers)" },
|
||||
args: {
|
||||
content: { type: "positional", description: "Text to remember", required: true },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
tags: { type: "string", description: "Comma-separated tags (e.g. task,context)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runRemember(args, args.content); },
|
||||
}),
|
||||
recall: defineCommand({
|
||||
meta: { name: "recall", description: "Search mesh memory by keyword or phrase" },
|
||||
args: {
|
||||
query: { type: "positional", description: "Search query", required: true },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runRecall(args, args.query); },
|
||||
}),
|
||||
remind: defineCommand({
|
||||
meta: { name: "remind", description: "Schedule a reminder or delayed message via the broker" },
|
||||
args: {
|
||||
message: { type: "positional", description: "Message text, or: list | cancel <id>", required: false },
|
||||
extra: { type: "positional", description: "Additional positional args", required: false },
|
||||
in: { type: "string", description: 'Deliver after duration: "2h", "30m", "90s"' },
|
||||
at: { type: "string", description: 'Deliver at time: "15:00" or ISO timestamp' },
|
||||
to: { type: "string", description: "Recipient (default: self). Name, @group, pubkey, or *" },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args, rawArgs }) {
|
||||
// Collect positional args from rawArgs (before any flags)
|
||||
const positionals = rawArgs.filter((a) => !a.startsWith("-"));
|
||||
await runRemind(args, positionals);
|
||||
},
|
||||
}),
|
||||
status: defineCommand({
|
||||
meta: { name: "status", description: "Check broker reachability for each joined mesh" },
|
||||
async run() { await runStatus(); },
|
||||
|
||||
@@ -444,6 +444,68 @@ Your message mode is "${messageMode}".
|
||||
return text(`Forgotten: ${id}`);
|
||||
}
|
||||
|
||||
// --- Scheduled messages ---
|
||||
case "schedule_reminder":
|
||||
case "send_later": {
|
||||
const sArgs = (args ?? {}) as {
|
||||
message?: string;
|
||||
to?: string;
|
||||
deliver_at?: number;
|
||||
in_seconds?: number;
|
||||
};
|
||||
if (!sArgs.message) return text(`${name}: \`message\` required`, true);
|
||||
const to = name === "schedule_reminder" ? "self" : (sArgs.to ?? "");
|
||||
if (name === "send_later" && !to) return text("send_later: `to` required", true);
|
||||
|
||||
let deliverAt: number;
|
||||
if (sArgs.deliver_at) {
|
||||
deliverAt = Number(sArgs.deliver_at);
|
||||
} else if (sArgs.in_seconds) {
|
||||
deliverAt = Date.now() + Number(sArgs.in_seconds) * 1_000;
|
||||
} else {
|
||||
return text(`${name}: provide \`deliver_at\` (ms timestamp) or \`in_seconds\``, true);
|
||||
}
|
||||
|
||||
// For send_later, resolve display name → pubkey if needed
|
||||
let targetSpec = to;
|
||||
if (name === "send_later" && !to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to) && to !== "self") {
|
||||
const peers = await client.listPeers();
|
||||
const match = peers.find((p) => p.displayName.toLowerCase() === to.toLowerCase());
|
||||
if (!match) {
|
||||
const names = peers.map((p) => p.displayName).join(", ");
|
||||
return text(`send_later: peer "${to}" not found. Online: ${names || "(none)"}`, true);
|
||||
}
|
||||
targetSpec = match.pubkey;
|
||||
}
|
||||
if (name === "schedule_reminder") {
|
||||
// Self-reminder: use own session pubkey
|
||||
targetSpec = client.getSessionPubkey() ?? "*";
|
||||
}
|
||||
|
||||
const result = await client.scheduleMessage(targetSpec, sArgs.message, deliverAt);
|
||||
if (!result) return text(`${name}: broker did not acknowledge — check connection`, true);
|
||||
const when = new Date(result.deliverAt).toISOString();
|
||||
return text(
|
||||
name === "schedule_reminder"
|
||||
? `Reminder scheduled (${result.scheduledId.slice(0, 8)}): "${sArgs.message.slice(0, 60)}" at ${when}`
|
||||
: `Message to "${to}" scheduled (${result.scheduledId.slice(0, 8)}) for ${when}`,
|
||||
);
|
||||
}
|
||||
case "list_scheduled": {
|
||||
const scheduled = await client.listScheduled();
|
||||
if (scheduled.length === 0) return text("No pending scheduled messages.");
|
||||
const lines = scheduled.map((m) =>
|
||||
`- [${m.id.slice(0, 8)}] → ${m.to === client.getSessionPubkey() ? "self (reminder)" : m.to} at ${new Date(m.deliverAt).toISOString()}: "${m.message.slice(0, 60)}${m.message.length > 60 ? "…" : ""}"`,
|
||||
);
|
||||
return text(`${scheduled.length} scheduled:\n${lines.join("\n")}`);
|
||||
}
|
||||
case "cancel_scheduled": {
|
||||
const { id: schedId } = (args ?? {}) as { id?: string };
|
||||
if (!schedId) return text("cancel_scheduled: `id` required", true);
|
||||
const ok = await client.cancelScheduled(schedId);
|
||||
return text(ok ? `Cancelled: ${schedId}` : `Not found or already fired: ${schedId}`, !ok);
|
||||
}
|
||||
|
||||
// --- Files ---
|
||||
case "share_file": {
|
||||
const { path: filePath, name: fileName, tags, to: fileTo } = (args ?? {}) as { path?: string; name?: string; tags?: string[]; to?: string };
|
||||
|
||||
@@ -564,6 +564,53 @@ export const TOOLS: Tool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
// --- Scheduled messages ---
|
||||
{
|
||||
name: "schedule_reminder",
|
||||
description:
|
||||
"Schedule a reminder message delivered back to yourself at a future time. The broker fires it as a push when the time arrives. Use to prompt yourself to check on something later.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string", description: "Reminder text" },
|
||||
deliver_at: { type: "number", description: "Unix timestamp (ms) when to deliver" },
|
||||
in_seconds: { type: "number", description: "Alternative to deliver_at: fire after N seconds" },
|
||||
},
|
||||
required: ["message"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "send_later",
|
||||
description:
|
||||
"Send a message to a peer, @group, or broadcast (*) at a future time. The broker holds it and delivers when the time arrives.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
to: { type: "string", description: "Recipient: display name, pubkey hex, @group, or *" },
|
||||
message: { type: "string", description: "Message text" },
|
||||
deliver_at: { type: "number", description: "Unix timestamp (ms) when to deliver" },
|
||||
in_seconds: { type: "number", description: "Alternative to deliver_at: fire after N seconds" },
|
||||
},
|
||||
required: ["to", "message"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_scheduled",
|
||||
description: "List all your pending scheduled messages: id, recipient, preview, and delivery time.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "cancel_scheduled",
|
||||
description: "Cancel a pending scheduled message before it fires.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Scheduled message ID" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Mesh info ---
|
||||
{
|
||||
name: "mesh_info",
|
||||
|
||||
@@ -399,6 +399,48 @@ export class BrokerClient {
|
||||
this.ws.send(JSON.stringify({ type: "forget", memoryId }));
|
||||
}
|
||||
|
||||
// --- Scheduled messages ---
|
||||
|
||||
/** Schedule a message for future delivery. Returns { scheduledId, deliverAt } or null on timeout. */
|
||||
async scheduleMessage(
|
||||
to: string,
|
||||
message: string,
|
||||
deliverAt: number,
|
||||
): Promise<{ scheduledId: string; deliverAt: number } | null> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
||||
return new Promise((resolve) => {
|
||||
const reqId = this.makeReqId();
|
||||
this.scheduledAckResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||
if (this.scheduledAckResolvers.delete(reqId)) resolve(null);
|
||||
}, 8_000) });
|
||||
this.ws!.send(JSON.stringify({ type: "schedule", to, message, deliverAt, _reqId: reqId }));
|
||||
});
|
||||
}
|
||||
|
||||
/** List all pending scheduled messages for this session. */
|
||||
async listScheduled(): Promise<Array<{ id: string; to: string; message: string; deliverAt: number; createdAt: number }>> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
|
||||
return new Promise((resolve) => {
|
||||
const reqId = this.makeReqId();
|
||||
this.scheduledListResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||
if (this.scheduledListResolvers.delete(reqId)) resolve([]);
|
||||
}, 5_000) });
|
||||
this.ws!.send(JSON.stringify({ type: "list_scheduled", _reqId: reqId }));
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel a scheduled message by id. Returns true if found and cancelled. */
|
||||
async cancelScheduled(scheduledId: string): Promise<boolean> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return false;
|
||||
return new Promise((resolve) => {
|
||||
const reqId = this.makeReqId();
|
||||
this.cancelScheduledResolvers.set(reqId, { resolve, timer: setTimeout(() => {
|
||||
if (this.cancelScheduledResolvers.delete(reqId)) resolve(false);
|
||||
}, 5_000) });
|
||||
this.ws!.send(JSON.stringify({ type: "cancel_scheduled", scheduledId, _reqId: reqId }));
|
||||
});
|
||||
}
|
||||
|
||||
/** Check delivery status of a sent message. */
|
||||
private messageStatusResolvers = new Map<string, { resolve: (result: { messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null) => void; timer: NodeJS.Timeout }>();
|
||||
private fileUrlResolvers = new Map<string, { resolve: (result: { url: string; name: string; encrypted?: boolean; sealedKey?: string } | null) => void; timer: NodeJS.Timeout }>();
|
||||
@@ -417,6 +459,9 @@ export class BrokerClient {
|
||||
private streamCreatedResolvers = new Map<string, { resolve: (id: string | null) => void; timer: NodeJS.Timeout }>();
|
||||
private streamListResolvers = new Map<string, { resolve: (streams: Array<{ id: string; name: string; createdBy: string; subscriberCount: number }>) => void; timer: NodeJS.Timeout }>();
|
||||
private streamDataHandlers = new Set<(data: { stream: string; data: unknown; publishedBy: string }) => void>();
|
||||
private scheduledAckResolvers = new Map<string, { resolve: (result: { scheduledId: string; deliverAt: number } | null) => void; timer: NodeJS.Timeout }>();
|
||||
private scheduledListResolvers = new Map<string, { resolve: (messages: Array<{ id: string; to: string; message: string; deliverAt: number; createdAt: number }>) => void; timer: NodeJS.Timeout }>();
|
||||
private cancelScheduledResolvers = new Map<string, { resolve: (ok: boolean) => void; timer: NodeJS.Timeout }>();
|
||||
|
||||
async messageStatus(messageId: string): Promise<{ messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
||||
@@ -1049,6 +1094,22 @@ export class BrokerClient {
|
||||
this.resolveFromMap(this.meshInfoResolvers, msgReqId, msg as Record<string, unknown>);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "scheduled_ack") {
|
||||
this.resolveFromMap(this.scheduledAckResolvers, msgReqId, {
|
||||
scheduledId: String(msg.scheduledId ?? ""),
|
||||
deliverAt: Number(msg.deliverAt ?? 0),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (msg.type === "scheduled_list") {
|
||||
const messages = (msg.messages as Array<{ id: string; to: string; message: string; deliverAt: number; createdAt: number }>) ?? [];
|
||||
this.resolveFromMap(this.scheduledListResolvers, msgReqId, messages);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "cancel_scheduled_ack") {
|
||||
this.resolveFromMap(this.cancelScheduledResolvers, msgReqId, Boolean(msg.ok));
|
||||
return;
|
||||
}
|
||||
if (msg.type === "error") {
|
||||
this.debug(`broker error: ${msg.code} ${msg.message}`);
|
||||
const id = msg.id ? String(msg.id) : null;
|
||||
@@ -1084,6 +1145,9 @@ export class BrokerClient {
|
||||
[this.contextResultsResolvers, []],
|
||||
[this.contextListResolvers, []],
|
||||
[this.streamListResolvers, []],
|
||||
[this.scheduledAckResolvers, null],
|
||||
[this.scheduledListResolvers, []],
|
||||
[this.cancelScheduledResolvers, false],
|
||||
[this.messageStatusResolvers, null],
|
||||
[this.grantFileAccessResolvers, false],
|
||||
[this.collectionListResolvers, []],
|
||||
|
||||
Reference in New Issue
Block a user