feat(cli): 1.34.7 → 1.34.13 — multi-session correctness train
Seven-ship sequence that took the daemon from "works for one session"
to "internally consistent for N sessions on one daemon." Architecture
invariant after 1.34.13: every shared store / channel scopes by
recipient (SSE demux at bind layer + token forwarding, inbox per-
recipient columns, outbox sender-session routing).
- 1.34.7 inbox flush + delete commands
- 1.34.8 seen_at column + TTL prune + first echo guard
- 1.34.9 broader echo guard + system-event polish + staleness warning
- 1.34.10 per-session SSE demux (SseFilterOptions) + universal daemon
(--mesh / --name deprecated) + daemon_started version stamp
- 1.34.11 inbox per-recipient column (storage half of 1.34.10)
- 1.34.12 daemon up detaches by default (logs to ~/.claudemesh/daemon/
daemon.log; service units explicitly pass --foreground)
- 1.34.13 MCP forwards session token on /v1/events — the actual fix
that activates 1.34.10's demux. Without this header the
daemon's session resolved null, filter was empty, every MCP
received the unfiltered global stream.
Roadmap entry at docs/roadmap.md captures the timeline + the four
known gaps tracked for follow-ups (launch env-var leak, broker
listPeers mesh-filter, kick on control-plane no-op, session caps as
first-class concept).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, openSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { runDaemon } from "~/daemon/run.js";
|
||||
import { ipc, IpcError } from "~/daemon/ipc/client.js";
|
||||
import { readRunningPid } from "~/daemon/lock.js";
|
||||
@@ -9,6 +13,15 @@ export interface DaemonOptions {
|
||||
publicHealth?: boolean;
|
||||
mesh?: string;
|
||||
displayName?: string;
|
||||
/** 1.34.12: keep the daemon attached to the current shell instead
|
||||
* of double-forking. Default behavior changed in 1.34.12 — `up`
|
||||
* now detaches by default and writes JSON logs to
|
||||
* ~/.claudemesh/daemon/daemon.log. Pass `--foreground` to get the
|
||||
* pre-1.34.12 behavior (logs streaming to stdout, blocks the
|
||||
* terminal until Ctrl-C). install-service and `claudemesh launch`'s
|
||||
* auto-spawn path always pass --foreground because their parents
|
||||
* (launchd / the launch helper) own the lifecycle. */
|
||||
foreground?: boolean;
|
||||
/** outbox-list status filter, set from boolean flags --failed/--pending/etc. */
|
||||
outboxStatus?: "pending" | "inflight" | "done" | "dead" | "aborted";
|
||||
/** outbox requeue: optional id to mint a fresh client_message_id with. */
|
||||
@@ -26,11 +39,40 @@ export async function runDaemonCommand(
|
||||
|
||||
case "up":
|
||||
case "start":
|
||||
// 1.34.10: `--mesh` and `--name` deprecated.
|
||||
// --mesh: daemon attaches to every joined mesh automatically;
|
||||
// pinning at start time blocks new meshes from being picked up.
|
||||
// --name: overrides the daemon-WS display name GLOBALLY across
|
||||
// every mesh, but each mesh has its own per-mesh display name
|
||||
// in config.json (set at `claudemesh join` time). Passing one
|
||||
// name flattens that out. Sessions advertise their own
|
||||
// CLAUDEMESH_DISPLAY_NAME at `claudemesh launch` time anyway,
|
||||
// and the daemon-WS presence is hidden from peer lists since
|
||||
// 1.32, so the daemon's display name isn't user-visible.
|
||||
if (opts.mesh) {
|
||||
process.stderr.write(
|
||||
`[claudemesh] --mesh on \`daemon up\` is deprecated; the daemon attaches to every joined mesh automatically. ` +
|
||||
`Ignoring --mesh ${opts.mesh}.\n`,
|
||||
);
|
||||
}
|
||||
if (opts.displayName) {
|
||||
process.stderr.write(
|
||||
`[claudemesh] --name on \`daemon up\` is deprecated; per-mesh display names live in config.json (set at join time), ` +
|
||||
`and session display names come from \`claudemesh launch --name\`. Ignoring --name ${opts.displayName}.\n`,
|
||||
);
|
||||
}
|
||||
// 1.34.12: detach by default. The pre-1.34.12 behavior streamed
|
||||
// JSON logs to the controlling terminal and blocked the shell —
|
||||
// fine for debugging, surprising for users who just want the
|
||||
// daemon "up." `--foreground` opts back into the old behavior;
|
||||
// launchd / systemd-user units always pass it because the unit
|
||||
// manager owns lifecycle and stdio redirection.
|
||||
if (!opts.foreground) {
|
||||
return spawnDetachedDaemon(opts);
|
||||
}
|
||||
return runDaemon({
|
||||
tcpEnabled: !opts.noTcp,
|
||||
publicHealthCheck: opts.publicHealth,
|
||||
mesh: opts.mesh,
|
||||
displayName: opts.displayName,
|
||||
});
|
||||
|
||||
case "help":
|
||||
@@ -74,19 +116,18 @@ USAGE
|
||||
claudemesh daemon <command> [options]
|
||||
|
||||
COMMANDS
|
||||
up | start start the daemon in the foreground
|
||||
up | start start the daemon (detached by default)
|
||||
status show running pid + IPC health
|
||||
version ipc + schema version of the running daemon
|
||||
down | stop stop the running daemon (SIGTERM, then wait)
|
||||
accept-host pin the current host fingerprint
|
||||
outbox list list local outbox rows (newest first)
|
||||
outbox requeue <id> re-enqueue an aborted / dead outbox row
|
||||
install-service --mesh <s> write launchd (macOS) / systemd-user (Linux) unit
|
||||
install-service write launchd (macOS) / systemd-user (Linux) unit
|
||||
uninstall-service remove the platform service unit
|
||||
|
||||
OPTIONS
|
||||
--mesh <slug> attach to / target this mesh
|
||||
--name <displayName> override CLAUDEMESH_DISPLAY_NAME
|
||||
--foreground keep daemon attached to terminal, JSON logs to stdout (1.34.12+)
|
||||
--no-tcp disable the loopback TCP listener (UDS only)
|
||||
--public-health expose /v1/health unauthenticated on TCP
|
||||
--json machine-readable output where supported
|
||||
@@ -192,9 +233,12 @@ async function runInstallService(opts: DaemonOptions): Promise<number> {
|
||||
}
|
||||
// Resolve the binary path. Prefer the running argv[0] when it's an
|
||||
// installed claudemesh binary; fall back to whichever `claudemesh` is
|
||||
// first on PATH. --mesh is now optional: omit it to attach to every
|
||||
// joined mesh (the 1.26.0 multi-mesh default); pass it to lock the
|
||||
// unit to a single mesh for testing or single-mesh hosts.
|
||||
// first on PATH.
|
||||
// 1.34.10: install-service no longer bakes --mesh into the unit. The
|
||||
// daemon attaches to every joined mesh by default, and pinning the
|
||||
// unit to one slug at install time was the source of the "joined a
|
||||
// new mesh but my service ignores it" footgun. If the user passes
|
||||
// --mesh anyway, we warn + ignore.
|
||||
let binary = process.argv[1] ?? "";
|
||||
if (!binary || /\.ts$/.test(binary) || /node_modules|src\/entrypoints/.test(binary)) {
|
||||
try {
|
||||
@@ -205,11 +249,19 @@ async function runInstallService(opts: DaemonOptions): Promise<number> {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if (opts.mesh) {
|
||||
process.stderr.write(
|
||||
`[claudemesh] --mesh on \`daemon install-service\` is deprecated and ignored; the daemon attaches to every joined mesh.\n`,
|
||||
);
|
||||
}
|
||||
if (opts.displayName) {
|
||||
process.stderr.write(
|
||||
`[claudemesh] --name on \`daemon install-service\` is deprecated and ignored; per-mesh names live in config.json, session names come from \`claudemesh launch --name\`.\n`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const r = installService({
|
||||
binaryPath: binary,
|
||||
...(opts.mesh ? { meshSlug: opts.mesh } : {}),
|
||||
...(opts.displayName ? { displayName: opts.displayName } : {}),
|
||||
});
|
||||
if (opts.json) {
|
||||
process.stdout.write(JSON.stringify({ ok: true, ...r }) + "\n");
|
||||
@@ -309,3 +361,71 @@ async function runStop(opts: DaemonOptions): Promise<number> {
|
||||
else process.stdout.write(`daemon: signaled but did not exit within 5s (pid ${pid})\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1.34.12: spawn the daemon as a detached background process. Re-execs
|
||||
* the same `claudemesh` binary with `daemon up --foreground` (so the
|
||||
* child runs the long-lived loop), redirects stdout/stderr to
|
||||
* ~/.claudemesh/daemon/daemon.log, and `unref()`s so the parent shell
|
||||
* can exit cleanly.
|
||||
*
|
||||
* The parent waits up to ~3s for the UDS socket to appear before
|
||||
* declaring success — that's the same liveness check `claudemesh launch`
|
||||
* uses, and it catches the "child crashed during boot" case (config
|
||||
* read failed, port bind failed, etc.) with an actionable error
|
||||
* pointing at the log file rather than silent loss.
|
||||
*/
|
||||
async function spawnDetachedDaemon(opts: DaemonOptions): Promise<number> {
|
||||
// Ensure the log directory exists before opening the FDs.
|
||||
mkdirSync(DAEMON_PATHS.DAEMON_DIR, { recursive: true, mode: 0o700 });
|
||||
const logPath = join(DAEMON_PATHS.DAEMON_DIR, "daemon.log");
|
||||
|
||||
// The CLI binary path. process.argv[1] is the entrypoint script the
|
||||
// node runtime is currently executing — for an installed CLI that's
|
||||
// .../bin/claudemesh, for `bun run` dev that's the local dist file.
|
||||
// Either way it's the right thing to re-exec.
|
||||
const binary = process.argv[1] ?? "claudemesh";
|
||||
const args = ["daemon", "up", "--foreground"];
|
||||
if (opts.noTcp) args.push("--no-tcp");
|
||||
if (opts.publicHealth) args.push("--public-health");
|
||||
|
||||
const out = openSync(logPath, "a");
|
||||
const err = openSync(logPath, "a");
|
||||
const child = spawn(process.execPath, [binary, ...args], {
|
||||
detached: true,
|
||||
stdio: ["ignore", out, err],
|
||||
env: process.env,
|
||||
});
|
||||
// Decouple the child from the parent's process group so closing the
|
||||
// shell doesn't SIGHUP the daemon.
|
||||
child.unref();
|
||||
|
||||
// Wait for the socket to appear — the daemon's IPC listener binds
|
||||
// ~immediately after the broker WS handshake starts, so socket
|
||||
// existence is a reliable "the daemon is alive enough to accept
|
||||
// requests" signal.
|
||||
const sockPath = DAEMON_PATHS.SOCK_FILE;
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 3_000) {
|
||||
if (existsSync(sockPath)) {
|
||||
if (opts.json) {
|
||||
process.stdout.write(JSON.stringify({ ok: true, detached: true, pid: child.pid, log: logPath }) + "\n");
|
||||
} else {
|
||||
process.stdout.write(` ✔ daemon started (pid ${child.pid})\n`);
|
||||
process.stdout.write(` → log: ${logPath}\n`);
|
||||
process.stdout.write(` → stop: claudemesh daemon down\n`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
await new Promise<void>((r) => setTimeout(r, 100));
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
process.stdout.write(JSON.stringify({ ok: false, detached: true, pid: child.pid, reason: "socket_not_appeared", log: logPath }) + "\n");
|
||||
} else {
|
||||
process.stderr.write(` ✘ daemon spawn timeout: socket did not appear within 3s\n`);
|
||||
process.stderr.write(` → check log: ${logPath}\n`);
|
||||
process.stderr.write(` → run foreground for live output: claudemesh daemon up --foreground\n`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
91
apps/cli/src/commands/inbox-actions.ts
Normal file
91
apps/cli/src/commands/inbox-actions.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* `claudemesh inbox flush` and `claudemesh inbox delete <id>` —
|
||||
* mutate the daemon's persistent inbox store
|
||||
* (`~/.claudemesh/daemon/inbox.db`) over IPC.
|
||||
*
|
||||
* 1.34.7: until this version, the only way to clean the inbox was a
|
||||
* raw `sqlite3 inbox.db "DELETE FROM inbox"` against the daemon's
|
||||
* private DB. That works but bypasses the IPC layer (and any future
|
||||
* lifecycle hooks on row removal), and is invisible to a user who
|
||||
* doesn't know the schema. These two verbs make the operation visible
|
||||
* + safe + scriptable.
|
||||
*/
|
||||
|
||||
import {
|
||||
tryFlushInboxViaDaemon,
|
||||
tryDeleteInboxRowViaDaemon,
|
||||
} from "~/services/bridge/daemon-route.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { dim } from "~/ui/styles.js";
|
||||
|
||||
export interface InboxFlushFlags {
|
||||
mesh?: string;
|
||||
/** ISO-8601 timestamp; deletes rows received_at < before. */
|
||||
before?: string;
|
||||
/** Required when neither --mesh nor --before is set, to prevent an
|
||||
* accidental "delete every row on every mesh". */
|
||||
all?: boolean;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runInboxFlush(flags: InboxFlushFlags): Promise<void> {
|
||||
const hasFilter = !!(flags.mesh || flags.before);
|
||||
if (!hasFilter && !flags.all) {
|
||||
if (flags.json) { process.stdout.write(JSON.stringify({ ok: false, error: "missing_filter" }) + "\n"); return; }
|
||||
render.info(dim(
|
||||
"Refusing to flush every row on every mesh.\n" +
|
||||
" Re-run with --mesh <slug>, --before <iso-timestamp>, or --all to confirm.",
|
||||
));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const removed = await tryFlushInboxViaDaemon({
|
||||
...(flags.mesh ? { mesh: flags.mesh } : {}),
|
||||
...(flags.before ? { beforeIso: flags.before } : {}),
|
||||
});
|
||||
|
||||
if (removed === null) {
|
||||
if (flags.json) { process.stdout.write(JSON.stringify({ ok: false, error: "daemon_unreachable" }) + "\n"); return; }
|
||||
render.info(dim("Daemon not reachable. Run `claudemesh daemon up` and retry."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
process.stdout.write(JSON.stringify({ ok: true, removed }) + "\n");
|
||||
return;
|
||||
}
|
||||
const scope = flags.mesh
|
||||
? `mesh "${flags.mesh}"`
|
||||
: flags.before
|
||||
? `older than ${flags.before}`
|
||||
: "all meshes";
|
||||
render.info(`✔ Flushed ${removed} message${removed === 1 ? "" : "s"} from ${scope}.`);
|
||||
}
|
||||
|
||||
export interface InboxDeleteFlags {
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runInboxDelete(id: string, flags: InboxDeleteFlags): Promise<void> {
|
||||
if (!id) {
|
||||
if (flags.json) { process.stdout.write(JSON.stringify({ ok: false, error: "missing_id" }) + "\n"); return; }
|
||||
render.info(dim("Usage: claudemesh inbox delete <message-id>"));
|
||||
process.exit(1);
|
||||
}
|
||||
const ok = await tryDeleteInboxRowViaDaemon(id);
|
||||
if (ok === null) {
|
||||
if (flags.json) { process.stdout.write(JSON.stringify({ ok: false, error: "daemon_unreachable" }) + "\n"); return; }
|
||||
render.info(dim("Daemon not reachable. Run `claudemesh daemon up` and retry."));
|
||||
process.exit(1);
|
||||
}
|
||||
if (!ok) {
|
||||
if (flags.json) { process.stdout.write(JSON.stringify({ ok: false, error: "not_found", id }) + "\n"); return; }
|
||||
render.info(dim(`No inbox row with id "${id}".`));
|
||||
process.exit(1);
|
||||
}
|
||||
if (flags.json) {
|
||||
process.stdout.write(JSON.stringify({ ok: true, id }) + "\n");
|
||||
return;
|
||||
}
|
||||
render.info(`✔ Deleted inbox row ${id}.`);
|
||||
}
|
||||
@@ -1,49 +1,101 @@
|
||||
/**
|
||||
* `claudemesh inbox` — read pending peer messages.
|
||||
* `claudemesh inbox` — read pending peer messages from the daemon's
|
||||
* persisted inbox (`~/.claudemesh/daemon/inbox.db`).
|
||||
*
|
||||
* Connects, waits briefly for push delivery, drains the buffer, prints.
|
||||
* Works best when message-mode is "inbox" or "off" (messages held at broker).
|
||||
* 1.34.0: switched from the legacy cold-path "open fresh broker WS,
|
||||
* drain in-memory buffer" flow to a daemon IPC read against `/v1/inbox`.
|
||||
* The cold path was structurally broken — the persistent inbox lives in
|
||||
* the daemon, and pushes land on its session-WS, not on a freshly-opened
|
||||
* standalone WS. The daemon-route `tryListInboxViaDaemon` returns rows
|
||||
* persisted across daemon restarts and surfaces them with the correct
|
||||
* mesh scoping (server-side mesh filter added in 1.34.0).
|
||||
*
|
||||
* Cold-path fallback removed: when the daemon isn't reachable, the
|
||||
* prior implementation returned an empty list anyway (no broker state
|
||||
* = no buffered pushes), so removing that path doesn't lose any
|
||||
* functionality. Strict mode emits a clear error via daemon-route.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect.js";
|
||||
import type { InboundPush } from "~/services/broker/facade.js";
|
||||
import { tryListInboxViaDaemon } from "~/services/bridge/daemon-route.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { bold, dim } from "~/ui/styles.js";
|
||||
|
||||
export interface InboxFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
wait?: number;
|
||||
/** Cap the number of rows returned by the daemon. Default 100. */
|
||||
limit?: number;
|
||||
/** 1.34.8: only show rows whose seen_at is NULL (i.e., never
|
||||
* surfaced via an interactive listing or live channel reminder).
|
||||
* When omitted, every row is returned and an interactive listing
|
||||
* stamps them seen as a side effect. */
|
||||
unread?: boolean;
|
||||
}
|
||||
|
||||
function formatMessage(msg: InboundPush): string {
|
||||
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}`;
|
||||
interface FormattedItem {
|
||||
sender_pubkey: string;
|
||||
sender_name: string;
|
||||
body: string | null;
|
||||
topic: string | null;
|
||||
received_at: string;
|
||||
mesh: string;
|
||||
}
|
||||
|
||||
function formatMessage(msg: FormattedItem, includeMesh: boolean): string {
|
||||
const text = msg.body ?? "[encrypted]";
|
||||
const from = msg.sender_name && msg.sender_name !== msg.sender_pubkey.slice(0, 8)
|
||||
? `${msg.sender_name} (${msg.sender_pubkey.slice(0, 8)})`
|
||||
: msg.sender_pubkey.slice(0, 8);
|
||||
const time = new Date(msg.received_at).toLocaleTimeString();
|
||||
const topicTag = msg.topic ? ` (#${msg.topic})` : "";
|
||||
const meshTag = includeMesh ? ` [${msg.mesh}]` : "";
|
||||
return ` ${bold(from)} ${dim(`${meshTag}${topicTag} ${time}`)}\n ${text}`;
|
||||
}
|
||||
|
||||
export async function runInbox(flags: InboxFlags): Promise<void> {
|
||||
const waitMs = (flags.wait ?? 1) * 1000;
|
||||
// Mesh resolution is owned by the daemon (it knows which meshes are
|
||||
// attached) — the CLI just forwards the user's --mesh flag through.
|
||||
// When omitted, the daemon's `/v1/inbox` honors the session-default
|
||||
// mesh on auth-token requests; out-of-session callers see rows from
|
||||
// every attached mesh. We don't pre-validate the mesh slug here so
|
||||
// the command works even from a launch tmpdir whose local
|
||||
// `config.json` only knows about the launch's mesh.
|
||||
const meshSlug = flags.mesh;
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
|
||||
const messages = client.drainPushBuffer();
|
||||
|
||||
if (flags.json) {
|
||||
process.stdout.write(JSON.stringify(messages, null, 2) + "\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
render.info(dim(`No messages on mesh "${mesh.slug}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
render.section(`inbox — ${mesh.slug} (${messages.length} message${messages.length === 1 ? "" : "s"})`);
|
||||
for (const msg of messages) {
|
||||
process.stdout.write(formatMessage(msg) + "\n\n");
|
||||
}
|
||||
const items = await tryListInboxViaDaemon(meshSlug, flags.limit ?? 100, {
|
||||
unreadOnly: flags.unread === true,
|
||||
// CLI is the canonical "I'm reading my inbox" path — let the daemon
|
||||
// auto-stamp seen_at on the rows we just rendered. The MCP welcome
|
||||
// path passes mark_seen=false instead and stamps explicitly after
|
||||
// the channel notification succeeds.
|
||||
markSeen: true,
|
||||
});
|
||||
if (items === null) {
|
||||
if (flags.json) { process.stdout.write("[]\n"); return; }
|
||||
render.info(dim("Daemon not reachable. Run `claudemesh daemon up` and retry."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
process.stdout.write(JSON.stringify(items, null, 2) + "\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
const scope = meshSlug ? `mesh "${meshSlug}"` : "any mesh";
|
||||
const filter = flags.unread ? "unread " : "";
|
||||
render.info(dim(`No ${filter}messages on ${scope}.`));
|
||||
return;
|
||||
}
|
||||
|
||||
const filterTag = flags.unread ? " unread" : "";
|
||||
const heading = meshSlug
|
||||
? `inbox — ${meshSlug} (${items.length}${filterTag} message${items.length === 1 ? "" : "s"})`
|
||||
: `inbox (${items.length}${filterTag} message${items.length === 1 ? "" : "s"})`;
|
||||
render.section(heading);
|
||||
// When the user didn't filter by mesh, surface the mesh slug per row
|
||||
// so they can tell apart rows from different meshes at a glance.
|
||||
for (const msg of items) {
|
||||
process.stdout.write(formatMessage(msg, !meshSlug) + "\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ async function ensureDaemonRunning(meshSlug: string, quiet: boolean): Promise<vo
|
||||
const res = await ensureDaemonReady({ budgetMs: 10_000, mesh: meshSlug });
|
||||
if (res.state === "up") {
|
||||
if (!quiet) render.ok("daemon already running");
|
||||
await warnIfDaemonStale(quiet);
|
||||
return;
|
||||
}
|
||||
if (res.state === "started") {
|
||||
@@ -71,10 +72,34 @@ async function ensureDaemonRunning(meshSlug: string, quiet: boolean): Promise<vo
|
||||
}
|
||||
render.warn(
|
||||
`daemon ${res.state}${res.reason ? `: ${res.reason}` : ""}`,
|
||||
"Run `claudemesh daemon up --mesh " + meshSlug + "` manually, then re-launch.",
|
||||
"Run `claudemesh daemon up` manually, then re-launch.",
|
||||
);
|
||||
}
|
||||
|
||||
/** 1.34.9: warn when the running daemon's version doesn't match the CLI
|
||||
* that's about to launch a session. `npm i -g claudemesh-cli` upgrades
|
||||
* the binaries on disk but doesn't restart a launchd / systemd-user
|
||||
* service or a foreground `claudemesh daemon up`, so users routinely
|
||||
* ship a fix to the CLI side and never see it because the WS lifecycle,
|
||||
* echo guards, and self-join filters all live in the long-running
|
||||
* daemon process. We probe `/v1/version` and emit a one-shot stderr
|
||||
* warning when CLI ≠ daemon. Best-effort; failures are silent. */
|
||||
async function warnIfDaemonStale(quiet: boolean): Promise<void> {
|
||||
if (quiet) return;
|
||||
try {
|
||||
const { ipc } = await import("~/daemon/ipc/client.js");
|
||||
const { VERSION } = await import("~/constants/urls.js");
|
||||
const res = await ipc<{ daemon_version?: string }>({ path: "/v1/version", timeoutMs: 1_500 });
|
||||
if (res.status !== 200) return;
|
||||
const daemonVersion = res.body.daemon_version ?? "";
|
||||
if (!daemonVersion || daemonVersion === VERSION) return;
|
||||
render.warn(
|
||||
`daemon is ${daemonVersion}, CLI is ${VERSION} — restart to pick up new fixes.`,
|
||||
"Run: `claudemesh daemon down && claudemesh daemon up` (no --mesh — daemon attaches to every joined mesh; restart the launchd / systemd-user unit if you installed one).",
|
||||
);
|
||||
} catch { /* swallow — version probe is best-effort */ }
|
||||
}
|
||||
|
||||
async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
|
||||
if (meshes.length === 1) return meshes[0]!;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user