feat(cli): claudemesh daemon — peer mesh runtime (v0.9.0)
Long-lived process that holds a persistent WS to the broker and exposes
a local IPC surface (UDS + bearer-auth TCP loopback). Implements the
v0.9.0 spec under .artifacts/specs/.
Core:
- daemon up | status | version | down | accept-host
- daemon outbox list [--failed|--pending|--inflight|--done|--aborted]
- daemon outbox requeue <id> [--new-client-id <id>]
- daemon install-service / uninstall-service (macOS launchd, Linux systemd)
IPC routes:
- /v1/version, /v1/health
- /v1/send (POST) — full §4.5.1 idempotency lookup table
- /v1/inbox (GET) — paged history
- /v1/events — SSE stream of message/peer_join/peer_leave/broker_status
- /v1/peers — broker passthrough
- /v1/profile — summary/status/visible/avatar/title/bio/capabilities
- /v1/outbox + /v1/outbox/requeue — operator recovery
Storage (SQLite via node:sqlite / bun:sqlite):
- outbox.db: pending/inflight/done/dead/aborted with audit columns
- inbox.db: dedupe by client_message_id, decrypts DMs via existing crypto
- BEGIN IMMEDIATE serialization for daemon-local accept races
Identity:
- host_fingerprint.json (machine-id || first-stable-mac)
- refuse-on-mismatch policy with `daemon accept-host` recovery
CLI integration:
- claudemesh send detects the daemon and routes through /v1/send when
present, falling back to bridge socket / cold path otherwise
Tests: 15-case coverage of the §4.5.1 IPC duplicate lookup table.
Spec arc preserved at .artifacts/specs/2026-05-03-daemon-{v1..v10}.md;
v0.9.0 implementation target locked at 2026-05-03-daemon-spec-v0.9.0.md;
deferred items at 2026-05-03-daemon-spec-broker-hardening-followups.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
59
apps/cli/src/daemon/lock.ts
Normal file
59
apps/cli/src/daemon/lock.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import { DAEMON_PATHS } from "./paths.js";
|
||||
|
||||
/**
|
||||
* Single-instance lock via PID file. Returns:
|
||||
* - 'acquired' — we hold the lock now, file written.
|
||||
* - 'already-running' — another live process owns it.
|
||||
* - 'stale' — file existed but the recorded PID is dead;
|
||||
* caller should treat as acquired (we overwrote it).
|
||||
*/
|
||||
export type LockResult = "acquired" | "already-running" | "stale";
|
||||
|
||||
export function acquireSingletonLock(): { result: LockResult; pid: number } {
|
||||
mkdirSync(dirname(DAEMON_PATHS.PID_FILE), { recursive: true, mode: 0o700 });
|
||||
|
||||
if (existsSync(DAEMON_PATHS.PID_FILE)) {
|
||||
const raw = readFileSync(DAEMON_PATHS.PID_FILE, "utf8").trim();
|
||||
const oldPid = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(oldPid) && oldPid > 0 && isProcessAlive(oldPid)) {
|
||||
return { result: "already-running", pid: oldPid };
|
||||
}
|
||||
// stale → unlink and re-acquire
|
||||
try { unlinkSync(DAEMON_PATHS.PID_FILE); } catch { /* race with another acquirer; tolerate */ }
|
||||
writeFileSync(DAEMON_PATHS.PID_FILE, String(process.pid), { mode: 0o600 });
|
||||
return { result: "stale", pid: process.pid };
|
||||
}
|
||||
|
||||
writeFileSync(DAEMON_PATHS.PID_FILE, String(process.pid), { mode: 0o600 });
|
||||
return { result: "acquired", pid: process.pid };
|
||||
}
|
||||
|
||||
export function releaseSingletonLock(): void {
|
||||
try {
|
||||
const raw = readFileSync(DAEMON_PATHS.PID_FILE, "utf8").trim();
|
||||
if (Number.parseInt(raw, 10) === process.pid) unlinkSync(DAEMON_PATHS.PID_FILE);
|
||||
} catch { /* file already gone, fine */ }
|
||||
}
|
||||
|
||||
export function readRunningPid(): number | null {
|
||||
try {
|
||||
const raw = readFileSync(DAEMON_PATHS.PID_FILE, "utf8").trim();
|
||||
const pid = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(pid) && pid > 0 && isProcessAlive(pid)) return pid;
|
||||
} catch { /* no pid file */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
// signal 0: no-op; throws if process doesn't exist or we lack permission.
|
||||
// EPERM means it does exist (just not ours), so treat as alive.
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return (err as NodeJS.ErrnoException).code === "EPERM";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user