From 3753a6e137ffe6bcda56079f1aa06e58d2e3a844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Mon, 4 May 2026 09:41:18 +0100 Subject: [PATCH] =?UTF-8?q?feat(cli):=201.27.0=20=E2=80=94=20state/memory?= =?UTF-8?q?=20through=20daemon=20+=20workspace=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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) --- apps/cli/CHANGELOG.md | 47 +++++++ apps/cli/package.json | 2 +- apps/cli/src/commands/broker-actions.ts | 9 ++ apps/cli/src/commands/recall.ts | 17 +++ apps/cli/src/commands/remember.ts | 13 ++ apps/cli/src/commands/state.ts | 30 +++++ apps/cli/src/daemon/broker.ts | 133 ++++++++++++++++++ apps/cli/src/daemon/ipc/server.ts | 135 ++++++++++++++++++- apps/cli/src/entrypoints/cli.ts | 27 +++- apps/cli/src/services/bridge/daemon-route.ts | 119 +++++++++++++++- 10 files changed, 523 insertions(+), 9 deletions(-) diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 1f024d6..6f4acfe 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -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 ` 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 `) route through `/v1/memory`. + Aggregates across attached meshes for `recall`; requires `--mesh` + for `remember`/`forget` when ambiguous. +- New `claudemesh workspace ` 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=` filter) or single key lookup + (`?key=&mesh=`). 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=&mesh=` — 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=` — 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 diff --git a/apps/cli/package.json b/apps/cli/package.json index f44fcd4..5eaa2ed 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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", diff --git a/apps/cli/src/commands/broker-actions.ts b/apps/cli/src/commands/broker-actions.ts index 3dfb27e..9df05bb 100644 --- a/apps/cli/src/commands/broker-actions.ts +++ b/apps/cli/src/commands/broker-actions.ts @@ -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 "); 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); }); diff --git a/apps/cli/src/commands/recall.ts b/apps/cli/src/commands/recall.ts index 00a2384..d8db88a 100644 --- a/apps/cli/src/commands/recall.ts +++ b/apps/cli/src/commands/recall.ts @@ -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 "); 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); diff --git a/apps/cli/src/commands/remember.ts b/apps/cli/src/commands/remember.ts index 5cf5ddb..2682de1 100644 --- a/apps/cli/src/commands/remember.ts +++ b/apps/cli/src/commands/remember.ts @@ -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); diff --git a/apps/cli/src/commands/state.ts b/apps/cli/src/commands/state.ts index 9f2efd5..26f7ce6 100644 --- a/apps/cli/src/commands/state.ts +++ b/apps/cli/src/commands/state.ts @@ -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 { + // 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 { + // 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(); diff --git a/apps/cli/src/daemon/broker.ts b/apps/cli/src/daemon/broker.ts index 51bb11e..c6b883d 100644 --- a/apps/cli/src/daemon/broker.ts +++ b/apps/cli/src/daemon/broker.ts @@ -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(); private skillListResolvers = new Map void; timer: NodeJS.Timeout }>(); private skillDataResolvers = new Map void; timer: NodeJS.Timeout }>(); + private stateGetResolvers = new Map void; timer: NodeJS.Timeout }>(); + private stateListResolvers = new Map void; timer: NodeJS.Timeout }>(); + private memoryStoreResolvers = new Map void; timer: NodeJS.Timeout }>(); + private memoryRecallResolvers = new Map 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 { + if (this._status !== "open" || !this.ws) return null; + return new Promise((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 { + if (this._status !== "open" || !this.ws) return []; + return new Promise((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 { + if (this._status !== "open" || !this.ws) return null; + return new Promise((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 { + if (this._status !== "open" || !this.ws) return []; + return new Promise((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; diff --git a/apps/cli/src/daemon/ipc/server.ts b/apps/cli/src/daemon/ipc/server.ts index 9e238a9..33a0da4 100644 --- a/apps/cli/src/daemon/ipc/server.ts +++ b/apps/cli/src/daemon/ipc/server.ts @@ -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= 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 & { 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), 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 | 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 & { 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), 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 | 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" }); diff --git a/apps/cli/src/entrypoints/cli.ts b/apps/cli/src/entrypoints/cli.ts index 8f10648..2c06a17 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -67,7 +67,7 @@ USAGE claudemesh join a mesh, then launch claudemesh launch --name --join join + launch in one step -Mesh +Mesh (alias: "workspace" — claudemesh workspace mirrors each) claudemesh create create a new mesh claudemesh join 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 { 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 "); 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; } diff --git a/apps/cli/src/services/bridge/daemon-route.ts b/apps/cli/src/services/bridge/daemon-route.ts index 27da1fb..10a9889 100644 --- a/apps/cli/src/services/bridge/daemon-route.ts +++ b/apps/cli/src/services/bridge/daemon-route.ts @@ -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 { +export async function tryListPeersViaDaemon(mesh?: string): Promise { 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 { } /** Try fetching mesh-published skills through the daemon. */ -export async function tryListSkillsViaDaemon(): Promise { +export async function tryListSkillsViaDaemon(mesh?: string): Promise { 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 { } /** Try fetching one skill body through the daemon. */ -export async function tryGetSkillViaDaemon(name: string): Promise { +export async function tryGetSkillViaDaemon(name: string, mesh?: string): Promise { 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 { + 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 { + 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 { + 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 { + 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 { + 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;