feat(cli): 1.27.0 — state/memory through daemon + workspace alias
extend the daemon thin-client surface to two more verb families: state get/set/list now routes through `/v1/state`, and remember/recall/forget through `/v1/memory`. same warm-path pattern as 1.25.0 — try the unix socket first, fall back to the cold ws path when the daemon is absent. multi-mesh aware (aggregates on read, requires `--mesh` for writes when ambiguous). also ships an early `claudemesh workspace <verb>` alias surface — bare teaser for the 1.28.0 mesh→workspace public rename. no-arg falls through to launch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,52 @@
|
||||
# Changelog
|
||||
|
||||
## 1.27.0 (2026-05-04) — state + memory through the daemon, workspace alias
|
||||
|
||||
Two more verb families now route through the local daemon's IPC for the
|
||||
warm path: `state get/set/list` and `remember/recall/forget`. Same
|
||||
pattern as 1.25.0 for peers/skills — try the socket first (~1 ms warm),
|
||||
fall back to the cold WS path when the daemon isn't running.
|
||||
|
||||
### What changed
|
||||
|
||||
- `claudemesh state get|set|list` route through `/v1/state` when the
|
||||
daemon socket is present. `--mesh <slug>` forwards as a query/body
|
||||
field. Single-mesh daemons auto-pick; multi-mesh daemons require
|
||||
`--mesh` for `state set`.
|
||||
- `claudemesh remember`, `claudemesh recall`, `claudemesh forget`
|
||||
(and `claudemesh memory <sub>`) route through `/v1/memory`.
|
||||
Aggregates across attached meshes for `recall`; requires `--mesh`
|
||||
for `remember`/`forget` when ambiguous.
|
||||
- New `claudemesh workspace <verb>` alias surface — early teaser for
|
||||
the 1.28.0 mesh→workspace public rename. Mirrors `list`, `info`,
|
||||
`create`, `join`, `delete`, `rename`, `share`, `launch`, `overview`.
|
||||
No-arg `claudemesh workspace` falls through to `launch` (same as
|
||||
bare `claudemesh`).
|
||||
|
||||
### IPC surface
|
||||
|
||||
- `GET /v1/state` — list (`?mesh=<slug>` filter) or single key lookup
|
||||
(`?key=<k>&mesh=<slug>`). Returns 404 with `{ error: "state_not_found" }`
|
||||
when missing.
|
||||
- `POST /v1/state` — `{ key, value, mesh? }`. 400 + attached list when
|
||||
multi-mesh and no `mesh` field.
|
||||
- `GET /v1/memory?q=<query>&mesh=<slug>` — recall. Aggregates across
|
||||
meshes, each match tagged with its `mesh` field.
|
||||
- `POST /v1/memory` — `{ content, tags?, mesh? }`. Returns
|
||||
`{ id, mesh }`.
|
||||
- `DELETE /v1/memory/:id?mesh=<slug>` — forget.
|
||||
- `ipc_features` gains `state` and `memory` keys.
|
||||
|
||||
### Why this matters
|
||||
|
||||
State and memory were the last verbs that opened a fresh broker WS on
|
||||
every invocation. Now they reuse the daemon's existing connection — the
|
||||
warm-path latency cliff (~150 ms cold WS handshake → ~1 ms IPC) extends
|
||||
to two more flows agents poll heavily.
|
||||
|
||||
The `workspace` alias is cosmetic but lays the groundwork for 1.28.0's
|
||||
documented rename without breaking anyone's muscle memory.
|
||||
|
||||
## 1.26.0 (2026-05-04) — multi-mesh daemon
|
||||
|
||||
The daemon now attaches to **all joined meshes simultaneously** by
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudemesh-cli",
|
||||
"version": "1.26.0",
|
||||
"version": "1.27.0",
|
||||
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { withMesh } from "./connect.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { tryBridge } from "~/services/bridge/client.js";
|
||||
import { tryForgetViaDaemon } from "~/services/bridge/daemon-route.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { bold, clay, dim } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
@@ -173,6 +174,14 @@ export async function runForget(id: string | undefined, opts: StateFlags): Promi
|
||||
render.err("Usage: claudemesh forget <memory-id>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
|
||||
// Daemon path first.
|
||||
if (await tryForgetViaDaemon(id, opts.mesh)) {
|
||||
if (opts.json) { console.log(JSON.stringify({ id, forgotten: true })); return EXIT.SUCCESS; }
|
||||
render.ok(`forgot ${dim(id.slice(0, 8))}`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
await client.forget(id);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { withMesh } from "./connect.js";
|
||||
import { tryRecallViaDaemon } from "~/services/bridge/daemon-route.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { bold, clay, dim } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
@@ -11,6 +12,22 @@ export async function recall(
|
||||
render.err("Usage: claudemesh recall <query>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
|
||||
// Daemon path first.
|
||||
const daemonMatches = await tryRecallViaDaemon(query, opts.mesh);
|
||||
if (daemonMatches !== null) {
|
||||
if (opts.json) { console.log(JSON.stringify(daemonMatches, null, 2)); return EXIT.SUCCESS; }
|
||||
if (daemonMatches.length === 0) { render.info(dim("no memories found.")); return EXIT.SUCCESS; }
|
||||
render.section(`memories (${daemonMatches.length})`);
|
||||
for (const m of daemonMatches) {
|
||||
const tags = m.tags.length ? dim(` [${m.tags.map((t) => clay(t)).join(dim(", "))}]`) : "";
|
||||
process.stdout.write(` ${bold(m.id.slice(0, 8))}${tags}\n`);
|
||||
process.stdout.write(` ${m.content}\n`);
|
||||
process.stdout.write(` ${dim(m.rememberedBy + " · " + new Date(m.rememberedAt).toLocaleString())}\n\n`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const memories = await client.recall(query);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { withMesh } from "./connect.js";
|
||||
import { tryRememberViaDaemon } from "~/services/bridge/daemon-route.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { dim } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
@@ -12,6 +13,18 @@ export async function remember(
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
const tags = opts.tags?.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
|
||||
// Daemon path first.
|
||||
const daemonRes = await tryRememberViaDaemon(content, tags, opts.mesh);
|
||||
if (daemonRes) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ id: daemonRes.id, content, tags, mesh: daemonRes.mesh }));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
render.ok("remembered", dim(daemonRes.id.slice(0, 8)));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const id = await client.remember(content, tags);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect.js";
|
||||
import { tryGetStateViaDaemon, tryListStateViaDaemon, trySetStateViaDaemon } from "~/services/bridge/daemon-route.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { bold, dim } from "~/ui/styles.js";
|
||||
|
||||
@@ -14,6 +15,16 @@ export interface StateFlags {
|
||||
}
|
||||
|
||||
export async function runStateGet(flags: StateFlags, key: string): Promise<void> {
|
||||
// Daemon path first.
|
||||
const daemonEntry = await tryGetStateViaDaemon(key, flags.mesh);
|
||||
if (daemonEntry !== null) {
|
||||
if (!daemonEntry) { render.info(dim("(not set)")); return; }
|
||||
if (flags.json) { console.log(JSON.stringify(daemonEntry, null, 2)); return; }
|
||||
const val = typeof daemonEntry.value === "string" ? daemonEntry.value : JSON.stringify(daemonEntry.value);
|
||||
render.info(val);
|
||||
render.info(dim(` set by ${daemonEntry.updatedBy} at ${new Date(daemonEntry.updatedAt).toLocaleString()}`));
|
||||
return;
|
||||
}
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const entry = await client.getState(key);
|
||||
if (!entry) {
|
||||
@@ -38,6 +49,12 @@ export async function runStateSet(flags: StateFlags, key: string, value: string)
|
||||
parsed = value;
|
||||
}
|
||||
|
||||
// Daemon path first.
|
||||
const daemonOk = await trySetStateViaDaemon(key, parsed, flags.mesh);
|
||||
if (daemonOk) {
|
||||
render.ok(`${bold(key)} = ${JSON.stringify(parsed)}`);
|
||||
return;
|
||||
}
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
await client.setState(key, parsed);
|
||||
render.ok(`${bold(key)} = ${JSON.stringify(parsed)}`);
|
||||
@@ -45,6 +62,19 @@ export async function runStateSet(flags: StateFlags, key: string, value: string)
|
||||
}
|
||||
|
||||
export async function runStateList(flags: StateFlags): Promise<void> {
|
||||
// Daemon path first.
|
||||
const daemonRows = await tryListStateViaDaemon(flags.mesh);
|
||||
if (daemonRows !== null) {
|
||||
if (flags.json) { console.log(JSON.stringify(daemonRows, null, 2)); return; }
|
||||
if (daemonRows.length === 0) { render.info(dim("(no state)")); return; }
|
||||
render.section(`state (${daemonRows.length})`);
|
||||
for (const e of daemonRows) {
|
||||
const val = typeof e.value === "string" ? e.value : JSON.stringify(e.value);
|
||||
process.stdout.write(` ${bold(e.key)}: ${val}\n`);
|
||||
process.stdout.write(` ${dim(e.updatedBy + " · " + new Date(e.updatedAt).toLocaleString())}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const entries = await client.listState();
|
||||
|
||||
|
||||
@@ -69,6 +69,21 @@ export interface SkillFull extends SkillSummary {
|
||||
manifest?: unknown;
|
||||
}
|
||||
|
||||
export interface StateRow {
|
||||
key: string;
|
||||
value: unknown;
|
||||
updatedBy: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MemoryRow {
|
||||
id: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
rememberedBy: string;
|
||||
rememberedAt: string;
|
||||
}
|
||||
|
||||
const HELLO_ACK_TIMEOUT_MS = 5_000;
|
||||
const SEND_ACK_TIMEOUT_MS = 15_000;
|
||||
const BACKOFF_CAPS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
|
||||
@@ -91,6 +106,10 @@ export class DaemonBrokerClient {
|
||||
private peerListResolvers = new Map<string, PendingPeerList>();
|
||||
private skillListResolvers = new Map<string, { resolve: (rows: SkillSummary[]) => void; timer: NodeJS.Timeout }>();
|
||||
private skillDataResolvers = new Map<string, { resolve: (row: SkillFull | null) => void; timer: NodeJS.Timeout }>();
|
||||
private stateGetResolvers = new Map<string, { resolve: (row: StateRow | null) => void; timer: NodeJS.Timeout }>();
|
||||
private stateListResolvers = new Map<string, { resolve: (rows: StateRow[]) => void; timer: NodeJS.Timeout }>();
|
||||
private memoryStoreResolvers = new Map<string, { resolve: (id: string | null) => void; timer: NodeJS.Timeout }>();
|
||||
private memoryRecallResolvers = new Map<string, { resolve: (rows: MemoryRow[]) => void; timer: NodeJS.Timeout }>();
|
||||
private sessionPubkey: string | null = null;
|
||||
private sessionSecretKey: string | null = null;
|
||||
private opens: Array<() => void> = [];
|
||||
@@ -226,6 +245,50 @@ export class DaemonBrokerClient {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "state_value" || msg.type === "state_data") {
|
||||
const reqId = String(msg._reqId ?? "");
|
||||
const pending = this.stateGetResolvers.get(reqId);
|
||||
if (pending) {
|
||||
this.stateGetResolvers.delete(reqId);
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve((msg.state ?? msg.row ?? null) as StateRow | null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "state_list") {
|
||||
const reqId = String(msg._reqId ?? "");
|
||||
const pending = this.stateListResolvers.get(reqId);
|
||||
if (pending) {
|
||||
this.stateListResolvers.delete(reqId);
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(Array.isArray(msg.entries) ? (msg.entries as StateRow[]) : []);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "memory_stored") {
|
||||
const reqId = String(msg._reqId ?? "");
|
||||
const pending = this.memoryStoreResolvers.get(reqId);
|
||||
if (pending) {
|
||||
this.memoryStoreResolvers.delete(reqId);
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(typeof msg.memoryId === "string" ? msg.memoryId : null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "memory_recall_result") {
|
||||
const reqId = String(msg._reqId ?? "");
|
||||
const pending = this.memoryRecallResolvers.get(reqId);
|
||||
if (pending) {
|
||||
this.memoryRecallResolvers.delete(reqId);
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(Array.isArray(msg.matches) ? (msg.matches as MemoryRow[]) : []);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "push" || msg.type === "inbound") {
|
||||
this.opts.onPush?.(msg);
|
||||
return;
|
||||
@@ -329,6 +392,76 @@ export class DaemonBrokerClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** Read a single shared state row. Null on disconnect / timeout / not-found. */
|
||||
async getState(key: string, timeoutMs = 5_000): Promise<StateRow | null> {
|
||||
if (this._status !== "open" || !this.ws) return null;
|
||||
return new Promise<StateRow | null>((resolve) => {
|
||||
const reqId = `sg-${++this.reqCounter}`;
|
||||
const timer = setTimeout(() => {
|
||||
if (this.stateGetResolvers.delete(reqId)) resolve(null);
|
||||
}, timeoutMs);
|
||||
this.stateGetResolvers.set(reqId, { resolve, timer });
|
||||
try { this.ws!.send(JSON.stringify({ type: "get_state", key, _reqId: reqId })); }
|
||||
catch { this.stateGetResolvers.delete(reqId); clearTimeout(timer); resolve(null); }
|
||||
});
|
||||
}
|
||||
|
||||
/** List all shared state rows in the mesh. */
|
||||
async listState(timeoutMs = 5_000): Promise<StateRow[]> {
|
||||
if (this._status !== "open" || !this.ws) return [];
|
||||
return new Promise<StateRow[]>((resolve) => {
|
||||
const reqId = `sl-${++this.reqCounter}`;
|
||||
const timer = setTimeout(() => {
|
||||
if (this.stateListResolvers.delete(reqId)) resolve([]);
|
||||
}, timeoutMs);
|
||||
this.stateListResolvers.set(reqId, { resolve, timer });
|
||||
try { this.ws!.send(JSON.stringify({ type: "list_state", _reqId: reqId })); }
|
||||
catch { this.stateListResolvers.delete(reqId); clearTimeout(timer); resolve([]); }
|
||||
});
|
||||
}
|
||||
|
||||
/** Set a shared state value. Fire-and-forget. */
|
||||
setState(key: string, value: unknown): void {
|
||||
if (this._status !== "open" || !this.ws) return;
|
||||
try { this.ws.send(JSON.stringify({ type: "set_state", key, value })); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** Store a memory in the mesh. Returns the assigned id, or null on timeout. */
|
||||
async remember(content: string, tags?: string[], timeoutMs = 5_000): Promise<string | null> {
|
||||
if (this._status !== "open" || !this.ws) return null;
|
||||
return new Promise<string | null>((resolve) => {
|
||||
const reqId = `mr-${++this.reqCounter}`;
|
||||
const timer = setTimeout(() => {
|
||||
if (this.memoryStoreResolvers.delete(reqId)) resolve(null);
|
||||
}, timeoutMs);
|
||||
this.memoryStoreResolvers.set(reqId, { resolve, timer });
|
||||
try { this.ws!.send(JSON.stringify({ type: "remember", content, tags, _reqId: reqId })); }
|
||||
catch { this.memoryStoreResolvers.delete(reqId); clearTimeout(timer); resolve(null); }
|
||||
});
|
||||
}
|
||||
|
||||
/** Search memories by relevance. */
|
||||
async recall(query: string, timeoutMs = 5_000): Promise<MemoryRow[]> {
|
||||
if (this._status !== "open" || !this.ws) return [];
|
||||
return new Promise<MemoryRow[]>((resolve) => {
|
||||
const reqId = `mc-${++this.reqCounter}`;
|
||||
const timer = setTimeout(() => {
|
||||
if (this.memoryRecallResolvers.delete(reqId)) resolve([]);
|
||||
}, timeoutMs);
|
||||
this.memoryRecallResolvers.set(reqId, { resolve, timer });
|
||||
try { this.ws!.send(JSON.stringify({ type: "recall", query, _reqId: reqId })); }
|
||||
catch { this.memoryRecallResolvers.delete(reqId); clearTimeout(timer); resolve([]); }
|
||||
});
|
||||
}
|
||||
|
||||
/** Forget a memory by id. Fire-and-forget. */
|
||||
forget(memoryId: string): void {
|
||||
if (this._status !== "open" || !this.ws) return;
|
||||
try { this.ws.send(JSON.stringify({ type: "forget", memoryId })); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** Set the daemon's profile (avatar/title/bio/capabilities). Fire-and-forget. */
|
||||
setProfile(profile: { avatar?: string; title?: string; bio?: string; capabilities?: string[] }): void {
|
||||
if (this._status !== "open" || !this.ws) return;
|
||||
|
||||
@@ -177,7 +177,7 @@ function makeHandler(opts: {
|
||||
respond(res, 200, {
|
||||
daemon_version: VERSION,
|
||||
ipc_api: "v1",
|
||||
ipc_features: ["version", "health", "send", "inbox", "events", "peers", "profile", "skills"],
|
||||
ipc_features: ["version", "health", "send", "inbox", "events", "peers", "profile", "skills", "state", "memory"],
|
||||
schema_version: 1,
|
||||
});
|
||||
return;
|
||||
@@ -224,6 +224,139 @@ function makeHandler(opts: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/v1/state") {
|
||||
if (!opts.brokers || opts.brokers.size === 0) {
|
||||
respond(res, 503, { error: "broker not initialised" });
|
||||
return;
|
||||
}
|
||||
const filterMesh = url.searchParams.get("mesh") ?? undefined;
|
||||
const key = url.searchParams.get("key");
|
||||
try {
|
||||
if (key) {
|
||||
// Single key lookup. Walk attached meshes; first match wins
|
||||
// (or ?mesh=<slug> scopes the search).
|
||||
for (const [slug, b] of opts.brokers.entries()) {
|
||||
if (filterMesh && filterMesh !== slug) continue;
|
||||
const row = await b.getState(key).catch(() => null);
|
||||
if (row) { respond(res, 200, { state: { ...row, mesh: slug } }); return; }
|
||||
}
|
||||
respond(res, 404, { error: "state_not_found", key });
|
||||
return;
|
||||
}
|
||||
// No key — list all entries across attached meshes.
|
||||
const all: Array<Record<string, unknown> & { mesh: string }> = [];
|
||||
for (const [slug, b] of opts.brokers.entries()) {
|
||||
if (filterMesh && filterMesh !== slug) continue;
|
||||
const rows = await b.listState().catch(() => []);
|
||||
for (const r of rows) all.push({ ...(r as unknown as Record<string, unknown>), mesh: slug });
|
||||
}
|
||||
respond(res, 200, { entries: all });
|
||||
} catch (e) {
|
||||
respond(res, 502, { error: "broker_unreachable", detail: String(e) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/v1/state") {
|
||||
if (!opts.brokers || opts.brokers.size === 0) {
|
||||
respond(res, 503, { error: "broker not initialised" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await readJsonBody(req, 256 * 1024) as Record<string, unknown> | null;
|
||||
if (!body || typeof body.key !== "string") {
|
||||
respond(res, 400, { error: "missing 'key' (string)" });
|
||||
return;
|
||||
}
|
||||
const requested = (typeof body.mesh === "string" ? body.mesh : null) || null;
|
||||
let chosen = requested;
|
||||
if (!chosen && opts.brokers.size === 1) chosen = opts.brokers.keys().next().value as string;
|
||||
if (!chosen) {
|
||||
respond(res, 400, { error: "mesh_required", attached: [...opts.brokers.keys()] });
|
||||
return;
|
||||
}
|
||||
const broker = opts.brokers.get(chosen);
|
||||
if (!broker) { respond(res, 404, { error: "mesh_not_attached", mesh: chosen }); return; }
|
||||
broker.setState(body.key, body.value);
|
||||
respond(res, 200, { ok: true, key: body.key, mesh: chosen });
|
||||
} catch (e) {
|
||||
respond(res, 400, { error: String(e) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/v1/memory") {
|
||||
if (!opts.brokers || opts.brokers.size === 0) {
|
||||
respond(res, 503, { error: "broker not initialised" });
|
||||
return;
|
||||
}
|
||||
const query = url.searchParams.get("q") ?? "";
|
||||
const filterMesh = url.searchParams.get("mesh") ?? undefined;
|
||||
try {
|
||||
const all: Array<Record<string, unknown> & { mesh: string }> = [];
|
||||
for (const [slug, b] of opts.brokers.entries()) {
|
||||
if (filterMesh && filterMesh !== slug) continue;
|
||||
const rows = await b.recall(query).catch(() => []);
|
||||
for (const r of rows) all.push({ ...(r as unknown as Record<string, unknown>), mesh: slug });
|
||||
}
|
||||
respond(res, 200, { matches: all });
|
||||
} catch (e) {
|
||||
respond(res, 502, { error: "broker_unreachable", detail: String(e) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/v1/memory") {
|
||||
if (!opts.brokers || opts.brokers.size === 0) {
|
||||
respond(res, 503, { error: "broker not initialised" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await readJsonBody(req, 256 * 1024) as Record<string, unknown> | null;
|
||||
if (!body || typeof body.content !== "string") {
|
||||
respond(res, 400, { error: "missing 'content' (string)" });
|
||||
return;
|
||||
}
|
||||
const requested = (typeof body.mesh === "string" ? body.mesh : null) || null;
|
||||
let chosen = requested;
|
||||
if (!chosen && opts.brokers.size === 1) chosen = opts.brokers.keys().next().value as string;
|
||||
if (!chosen) {
|
||||
respond(res, 400, { error: "mesh_required", attached: [...opts.brokers.keys()] });
|
||||
return;
|
||||
}
|
||||
const broker = opts.brokers.get(chosen);
|
||||
if (!broker) { respond(res, 404, { error: "mesh_not_attached", mesh: chosen }); return; }
|
||||
const tags = Array.isArray(body.tags) ? body.tags.filter((t) => typeof t === "string") as string[] : undefined;
|
||||
const id = await broker.remember(body.content, tags);
|
||||
if (!id) { respond(res, 502, { error: "remember_timeout" }); return; }
|
||||
respond(res, 200, { id, mesh: chosen });
|
||||
} catch (e) {
|
||||
respond(res, 400, { error: String(e) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "DELETE" && url.pathname.startsWith("/v1/memory/")) {
|
||||
if (!opts.brokers || opts.brokers.size === 0) {
|
||||
respond(res, 503, { error: "broker not initialised" });
|
||||
return;
|
||||
}
|
||||
const id = decodeURIComponent(url.pathname.slice("/v1/memory/".length));
|
||||
if (!id) { respond(res, 400, { error: "missing memory id" }); return; }
|
||||
const requested = url.searchParams.get("mesh");
|
||||
let chosen = requested;
|
||||
if (!chosen && opts.brokers.size === 1) chosen = opts.brokers.keys().next().value as string;
|
||||
if (!chosen) {
|
||||
respond(res, 400, { error: "mesh_required", attached: [...opts.brokers.keys()] });
|
||||
return;
|
||||
}
|
||||
const broker = opts.brokers.get(chosen);
|
||||
if (!broker) { respond(res, 404, { error: "mesh_not_attached", mesh: chosen }); return; }
|
||||
broker.forget(id);
|
||||
respond(res, 200, { ok: true, id, mesh: chosen });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/v1/skills") {
|
||||
if (!opts.brokers || opts.brokers.size === 0) {
|
||||
respond(res, 503, { error: "broker not initialised" });
|
||||
|
||||
@@ -67,7 +67,7 @@ USAGE
|
||||
claudemesh <invite-url> join a mesh, then launch
|
||||
claudemesh launch --name <n> --join <url> join + launch in one step
|
||||
|
||||
Mesh
|
||||
Mesh (alias: "workspace" — claudemesh workspace <verb> mirrors each)
|
||||
claudemesh create <name> create a new mesh
|
||||
claudemesh join <url> join a mesh (accepts short /i/ or long /join/ link)
|
||||
claudemesh launch [slug] launch Claude Code on a mesh (alias: connect)
|
||||
@@ -324,6 +324,31 @@ async function main(): Promise<void> {
|
||||
case "delete": case "rm": { const { deleteMesh } = await import("~/commands/delete-mesh.js"); process.exit(await deleteMesh(positionals[0] ?? "", { yes: !!flags.y || !!flags.yes })); break; }
|
||||
case "rename": { const { rename } = await import("~/commands/rename.js"); process.exit(await rename(positionals[0] ?? "", positionals[1] ?? "")); break; }
|
||||
case "share": case "invite": { const { invite } = await import("~/commands/invite.js"); process.exit(await invite(positionals[0], { mesh: flags.mesh as string, json: !!flags.json })); break; }
|
||||
// workspace — alias surface for mesh-management verbs (v1.27.0 teaser; full
|
||||
// rename arrives in 1.28.0). Each sub mirrors an existing top-level verb.
|
||||
case "workspace": {
|
||||
const sub = positionals[0];
|
||||
if (!sub || sub === "launch" || sub === "connect" || sub === "open") {
|
||||
const { runLaunch } = await import("~/commands/launch.js");
|
||||
await runLaunch({
|
||||
mesh: positionals[1] ?? flags.mesh as string,
|
||||
name: flags.name as string,
|
||||
join: flags.join as string,
|
||||
yes: !!flags.y || !!flags.yes,
|
||||
resume: flags.resume as string,
|
||||
}, process.argv.slice(2));
|
||||
}
|
||||
else if (sub === "list" || sub === "ls") { const { runList } = await import("~/commands/list.js"); await runList(); }
|
||||
else if (sub === "info") { const { runInfo } = await import("~/commands/info.js"); await runInfo({}); }
|
||||
else if (sub === "create" || sub === "new") { const { newMesh } = await import("~/commands/new.js"); process.exit(await newMesh(positionals[1] ?? "", { json: !!flags.json })); }
|
||||
else if (sub === "join" || sub === "add") { const { runJoin } = await import("~/commands/join.js"); await runJoin(positionals.slice(1)); }
|
||||
else if (sub === "delete" || sub === "rm") { const { deleteMesh } = await import("~/commands/delete-mesh.js"); process.exit(await deleteMesh(positionals[1] ?? "", { yes: !!flags.y || !!flags.yes })); }
|
||||
else if (sub === "rename") { const { rename } = await import("~/commands/rename.js"); process.exit(await rename(positionals[1] ?? "", positionals[2] ?? "")); }
|
||||
else if (sub === "share" || sub === "invite") { const { invite } = await import("~/commands/invite.js"); process.exit(await invite(positionals[1], { mesh: flags.mesh as string, json: !!flags.json })); }
|
||||
else if (sub === "overview") { const { runMe } = await import("~/commands/me.js"); process.exit(await runMe({ mesh: flags.mesh as string, json: !!flags.json })); }
|
||||
else { console.error("Usage: claudemesh workspace <list|info|create|join|delete|rename|share|launch|overview>"); process.exit(EXIT.INVALID_ARGS); }
|
||||
break;
|
||||
}
|
||||
case "disconnect": { const { runDisconnect } = await import("~/commands/kick.js"); process.exit(await runDisconnect(positionals[0], { mesh: flags.mesh as string, stale: flags.stale as string, all: !!flags.all })); break; }
|
||||
case "kick": { const { runKick } = await import("~/commands/kick.js"); process.exit(await runKick(positionals[0], { mesh: flags.mesh as string, stale: flags.stale as string, all: !!flags.all })); break; }
|
||||
case "ban": { const { runBan } = await import("~/commands/ban.js"); process.exit(await runBan(positionals[0], { mesh: flags.mesh as string })); break; }
|
||||
|
||||
@@ -7,13 +7,17 @@ import { existsSync } from "node:fs";
|
||||
import { ipc } from "~/daemon/ipc/client.js";
|
||||
import { DAEMON_PATHS } from "~/daemon/paths.js";
|
||||
|
||||
function meshQuery(mesh?: string): string {
|
||||
return mesh ? `?mesh=${encodeURIComponent(mesh)}` : "";
|
||||
}
|
||||
|
||||
/** Try fetching the peer list through the daemon (~1ms warm IPC).
|
||||
* Returns null when the daemon socket isn't present so the caller can
|
||||
* fall back to bridge / cold paths. */
|
||||
export async function tryListPeersViaDaemon(): Promise<unknown[] | null> {
|
||||
export async function tryListPeersViaDaemon(mesh?: string): Promise<unknown[] | null> {
|
||||
if (!existsSync(DAEMON_PATHS.SOCK_FILE)) return null;
|
||||
try {
|
||||
const res = await ipc<{ peers?: unknown[] }>({ path: "/v1/peers", timeoutMs: 3_000 });
|
||||
const res = await ipc<{ peers?: unknown[] }>({ path: `/v1/peers${meshQuery(mesh)}`, timeoutMs: 3_000 });
|
||||
if (res.status !== 200) return null;
|
||||
return Array.isArray(res.body.peers) ? res.body.peers : [];
|
||||
} catch (err) {
|
||||
@@ -24,10 +28,10 @@ export async function tryListPeersViaDaemon(): Promise<unknown[] | null> {
|
||||
}
|
||||
|
||||
/** Try fetching mesh-published skills through the daemon. */
|
||||
export async function tryListSkillsViaDaemon(): Promise<unknown[] | null> {
|
||||
export async function tryListSkillsViaDaemon(mesh?: string): Promise<unknown[] | null> {
|
||||
if (!existsSync(DAEMON_PATHS.SOCK_FILE)) return null;
|
||||
try {
|
||||
const res = await ipc<{ skills?: unknown[] }>({ path: "/v1/skills", timeoutMs: 3_000 });
|
||||
const res = await ipc<{ skills?: unknown[] }>({ path: `/v1/skills${meshQuery(mesh)}`, timeoutMs: 3_000 });
|
||||
if (res.status !== 200) return null;
|
||||
return Array.isArray(res.body.skills) ? res.body.skills : [];
|
||||
} catch (err) {
|
||||
@@ -38,11 +42,11 @@ export async function tryListSkillsViaDaemon(): Promise<unknown[] | null> {
|
||||
}
|
||||
|
||||
/** Try fetching one skill body through the daemon. */
|
||||
export async function tryGetSkillViaDaemon(name: string): Promise<unknown | null> {
|
||||
export async function tryGetSkillViaDaemon(name: string, mesh?: string): Promise<unknown | null> {
|
||||
if (!existsSync(DAEMON_PATHS.SOCK_FILE)) return null;
|
||||
try {
|
||||
const res = await ipc<{ skill?: unknown }>({
|
||||
path: `/v1/skills/${encodeURIComponent(name)}`,
|
||||
path: `/v1/skills/${encodeURIComponent(name)}${meshQuery(mesh)}`,
|
||||
timeoutMs: 3_000,
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
@@ -51,6 +55,109 @@ export async function tryGetSkillViaDaemon(name: string): Promise<unknown | null
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// --- state ---
|
||||
|
||||
export type StateEntry = {
|
||||
key: string;
|
||||
value: unknown;
|
||||
updatedBy: string;
|
||||
updatedAt: string;
|
||||
mesh?: string;
|
||||
};
|
||||
|
||||
/** Try reading a single state key through the daemon. Returns:
|
||||
* - the entry when the daemon found it
|
||||
* - undefined when the daemon ran but the key is unset (404)
|
||||
* - null when the daemon socket isn't present (caller falls back) */
|
||||
export async function tryGetStateViaDaemon(key: string, mesh?: string): Promise<StateEntry | undefined | null> {
|
||||
if (!existsSync(DAEMON_PATHS.SOCK_FILE)) return null;
|
||||
try {
|
||||
const path = `/v1/state?key=${encodeURIComponent(key)}${mesh ? `&mesh=${encodeURIComponent(mesh)}` : ""}`;
|
||||
const res = await ipc<{ state?: StateEntry; error?: string }>({ path, timeoutMs: 3_000 });
|
||||
if (res.status === 404) return undefined;
|
||||
if (res.status !== 200) return null;
|
||||
return res.body.state ?? undefined;
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (/ENOENT|ECONNREFUSED|ipc_timeout/.test(msg)) return null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function tryListStateViaDaemon(mesh?: string): Promise<StateEntry[] | null> {
|
||||
if (!existsSync(DAEMON_PATHS.SOCK_FILE)) return null;
|
||||
try {
|
||||
const res = await ipc<{ entries?: StateEntry[] }>({ path: `/v1/state${meshQuery(mesh)}`, timeoutMs: 3_000 });
|
||||
if (res.status !== 200) return null;
|
||||
return Array.isArray(res.body.entries) ? res.body.entries : [];
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (/ENOENT|ECONNREFUSED|ipc_timeout/.test(msg)) return null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function trySetStateViaDaemon(key: string, value: unknown, mesh?: string): Promise<boolean> {
|
||||
if (!existsSync(DAEMON_PATHS.SOCK_FILE)) return false;
|
||||
try {
|
||||
const res = await ipc<{ ok?: boolean; error?: string }>({
|
||||
method: "POST",
|
||||
path: "/v1/state",
|
||||
timeoutMs: 3_000,
|
||||
body: { key, value, ...(mesh ? { mesh } : {}) },
|
||||
});
|
||||
return res.status === 200 && res.body.ok === true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// --- memory ---
|
||||
|
||||
export type MemoryEntry = {
|
||||
id: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
rememberedBy: string;
|
||||
rememberedAt: string;
|
||||
mesh?: string;
|
||||
};
|
||||
|
||||
export async function tryRememberViaDaemon(content: string, tags?: string[], mesh?: string): Promise<{ id: string; mesh?: string } | null> {
|
||||
if (!existsSync(DAEMON_PATHS.SOCK_FILE)) return null;
|
||||
try {
|
||||
const res = await ipc<{ id?: string; mesh?: string; error?: string }>({
|
||||
method: "POST",
|
||||
path: "/v1/memory",
|
||||
timeoutMs: 5_000,
|
||||
body: { content, ...(tags?.length ? { tags } : {}), ...(mesh ? { mesh } : {}) },
|
||||
});
|
||||
if (res.status !== 200 || !res.body.id) return null;
|
||||
return { id: res.body.id, mesh: res.body.mesh };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function tryRecallViaDaemon(query: string, mesh?: string): Promise<MemoryEntry[] | null> {
|
||||
if (!existsSync(DAEMON_PATHS.SOCK_FILE)) return null;
|
||||
try {
|
||||
const path = `/v1/memory?q=${encodeURIComponent(query)}${mesh ? `&mesh=${encodeURIComponent(mesh)}` : ""}`;
|
||||
const res = await ipc<{ matches?: MemoryEntry[] }>({ path, timeoutMs: 5_000 });
|
||||
if (res.status !== 200) return null;
|
||||
return Array.isArray(res.body.matches) ? res.body.matches : [];
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (/ENOENT|ECONNREFUSED|ipc_timeout/.test(msg)) return null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function tryForgetViaDaemon(id: string, mesh?: string): Promise<boolean> {
|
||||
if (!existsSync(DAEMON_PATHS.SOCK_FILE)) return false;
|
||||
try {
|
||||
const path = `/v1/memory/${encodeURIComponent(id)}${meshQuery(mesh)}`;
|
||||
const res = await ipc<{ ok?: boolean }>({ method: "DELETE", path, timeoutMs: 3_000 });
|
||||
return res.status === 200 && res.body.ok === true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
export type DaemonSendOk = {
|
||||
ok: true;
|
||||
messageId: string;
|
||||
|
||||
Reference in New Issue
Block a user