@lyric_dev/data-loom 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,6 +38,31 @@ With no argument it uses the current directory. Then open <http://127.0.0.1:4317
38
38
 
39
39
  > Published to npm automatically by CI on version tags (`vX.Y.Z`) via GitHub Actions.
40
40
 
41
+ ## Run it always-on (background + autostart)
42
+
43
+ The command above runs in the foreground and stops when you close the terminal. Since the daemon also hosts the MCP endpoint, you usually want it always running. Manage a detached background daemon with:
44
+
45
+ ```
46
+ data-loom start [C:\path\to\project] # launch detached; returns immediately
47
+ data-loom status # is it running? where are the logs?
48
+ data-loom stop # stop the background daemon
49
+ data-loom restart [path] # stop then start
50
+ ```
51
+
52
+ There is at most one instance: `start` while one is already running is a no-op that just reports it. Background output goes to a log file (path shown by `status`) since there's no attached console — on Windows, `%LOCALAPPDATA%\data-loom\daemon.log` (macOS `~/Library/Application Support/data-loom/`, Linux `${XDG_STATE_HOME:-~/.local/state}/data-loom/`).
53
+
54
+ To have it launch automatically when you log in:
55
+
56
+ ```
57
+ data-loom autostart enable # register a per-user login item AND start it now
58
+ data-loom autostart status # is autostart registered?
59
+ data-loom autostart disable # remove the login item (does not stop a running daemon)
60
+ ```
61
+
62
+ `enable` also starts the daemon immediately; pass `--no-start` to only register for next login. The login item is per-user and needs no admin rights — a Startup-folder shortcut on Windows, a LaunchAgent on macOS, an XDG autostart entry on Linux.
63
+
64
+ Everything is reversible: `data-loom stop`, `data-loom autostart disable`, and `data-loom disconnect claude-desktop` (below) undo each side effect, and each is idempotent.
65
+
41
66
  ## Run from source (development)
42
67
 
43
68
  Requires Node.js ≥ 20.
@@ -66,7 +91,15 @@ The running daemon also **hosts an MCP server** over HTTP, so your own Claude se
66
91
  claude mcp add --transport http --scope user data-loom http://127.0.0.1:4317/mcp
67
92
  ```
68
93
 
69
- The MCP server lives in the daemon, so **DataLoom must be running** for the tools to be reachable (start it with `npx @lyric_dev/data-loom "C:\path\to\your\project"`). It binds to loopback only.
94
+ The MCP server lives in the daemon, so **DataLoom must be running** for the tools to be reachable (start it with `data-loom start` or `npx @lyric_dev/data-loom "C:\path\to\your\project"`). It binds to loopback only.
95
+
96
+ **Claude Desktop** reaches the same daemon — register it with:
97
+
98
+ ```
99
+ data-loom connect claude-desktop
100
+ ```
101
+
102
+ This adds a `data-loom` entry to Claude Desktop's `claude_desktop_config.json` (additively — your other servers are untouched), pointing at the same loopback endpoint via Claude Desktop's native remote-MCP support. Restart Claude Desktop to pick it up. On older Claude Desktop versions without native remote support, pass `--bridge` to register a stdio↔HTTP bridge (`npx mcp-remote`) instead. Remove it any time with `data-loom disconnect claude-desktop`. As with Claude Code, the daemon must be running for the tools to appear.
70
103
 
71
104
  2. In any project, ask Claude something like *"review DataLoom's open proposals and set the dependencies."* On connect, the server asks Claude to surface any proposal that hasn't been reviewed for dependencies yet, propose the edges, and **confirm with you before writing**. It exposes these tools:
72
105
  - `list_projects` — the selectable OpenSpec workspaces plus the current selection (read-only), to discover/confirm a `project` path.
@@ -81,6 +114,17 @@ The running daemon also **hosts an MCP server** over HTTP, so your own Claude se
81
114
 
82
115
  > **Upgrading from an earlier version?** The MCP server used to be a per-project stdio registration (`claude mcp add data-loom -- npx … mcp "<path>"`). That mode is gone. Remove any old per-project `data-loom` registrations and add the single user-scope HTTP one above.
83
116
 
117
+ ## Security
118
+
119
+ DataLoom runs entirely on `127.0.0.1` and is built for a single local user.
120
+
121
+ - **Loopback only** — the daemon (dashboard, WebSocket, and MCP endpoint) binds to the loopback interface and is never exposed to the network.
122
+ - **Host + Origin validated** — every HTTP request and WebSocket upgrade is checked: requests with a non-loopback `Host` (DNS-rebinding) or a non-loopback `Origin` (cross-site requests from a web page) are rejected. Native MCP clients (which send no `Origin`) are allowed. This is what keeps a random website you visit from driving the daemon.
123
+ - **No secrets** — the MCP tools carry only proposal text and change names; the topology view redacts server commands/args and shows scheme+host only. No API keys or config are read or returned, and the daemon holds no credential.
124
+ - **Bounded** — request bodies are capped (4 MB) and MCP sessions are capped and idle-evicted, so a local client can't exhaust memory.
125
+
126
+ The MCP tools can read and write any OpenSpec workspace path you point them at (this is intentional, so the tool can stay LLM-provider-independent). That means: treat the daemon as you would any local dev server — fine for your own machine, not something to run on a shared/multi-user host.
127
+
84
128
  ## Design principles
85
129
 
86
130
  - **Derived, not stored** — phase/order is a pure function of the OpenSpec files, recomputed on every change. Nothing about ordering is persisted.
@@ -0,0 +1,141 @@
1
+ // Per-user login autostart: register the background daemon to launch when the
2
+ // user logs in, using the native, no-elevation mechanism for each OS —
3
+ // Windows a shortcut in the per-user Startup folder
4
+ // macOS a LaunchAgent plist with RunAtLoad
5
+ // Linux an XDG autostart .desktop entry
6
+ // Every registration launches `<node> <script> start`, so a login-triggered
7
+ // launch shares the exact same detached/single-instance/logging path as a
8
+ // manual `data-loom start`.
9
+ import { spawn } from "node:child_process";
10
+ import { writeFile, rm, access, mkdir } from "node:fs/promises";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ const NODE = process.execPath;
14
+ const SCRIPT = process.argv[1];
15
+ const LABEL = "dev.lyric.data-loom";
16
+ function exists(p) {
17
+ return access(p).then(() => true, () => false);
18
+ }
19
+ // ---- Windows: Startup-folder shortcut -------------------------------------
20
+ function winStartupLnk() {
21
+ const appData = process.env.APPDATA ?? join(homedir(), "AppData", "Roaming");
22
+ return join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "data-loom.lnk");
23
+ }
24
+ function runPowerShell(script) {
25
+ return new Promise((resolve, reject) => {
26
+ const ps = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { windowsHide: true, stdio: "ignore" });
27
+ ps.on("error", reject);
28
+ ps.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`powershell exited ${code}`))));
29
+ });
30
+ }
31
+ async function winEnable() {
32
+ const lnk = winStartupLnk();
33
+ await mkdir(dirname(lnk), { recursive: true });
34
+ // Create the .lnk via WScript.Shell; WindowStyle 7 = minimized so the brief
35
+ // launcher console doesn't grab focus (the daemon itself is detached).
36
+ const q = (s) => `'${s.replace(/'/g, "''")}'`;
37
+ await runPowerShell([
38
+ "$s = (New-Object -ComObject WScript.Shell).CreateShortcut(" + q(lnk) + ")",
39
+ "$s.TargetPath = " + q(NODE),
40
+ '$s.Arguments = ' + q(`"${SCRIPT}" start`),
41
+ "$s.WindowStyle = 7",
42
+ "$s.Description = 'DataLoom background daemon'",
43
+ "$s.Save()",
44
+ ].join("; "));
45
+ }
46
+ async function winDisable() {
47
+ await rm(winStartupLnk(), { force: true }).catch(() => { });
48
+ }
49
+ // ---- macOS: LaunchAgent ----------------------------------------------------
50
+ function macPlistPath() {
51
+ return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
52
+ }
53
+ async function macEnable() {
54
+ const path = macPlistPath();
55
+ await mkdir(dirname(path), { recursive: true });
56
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
57
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
58
+ <plist version="1.0">
59
+ <dict>
60
+ <key>Label</key><string>${LABEL}</string>
61
+ <key>ProgramArguments</key>
62
+ <array>
63
+ <string>${NODE}</string>
64
+ <string>${SCRIPT}</string>
65
+ <string>start</string>
66
+ </array>
67
+ <key>RunAtLoad</key><true/>
68
+ </dict>
69
+ </plist>
70
+ `;
71
+ await writeFile(path, plist, "utf8");
72
+ await new Promise((resolve) => {
73
+ const p = spawn("launchctl", ["load", "-w", path], { stdio: "ignore" });
74
+ p.on("error", () => resolve()); // best-effort; the plist is written regardless
75
+ p.on("exit", () => resolve());
76
+ });
77
+ }
78
+ async function macDisable() {
79
+ const path = macPlistPath();
80
+ if (await exists(path)) {
81
+ await new Promise((resolve) => {
82
+ const p = spawn("launchctl", ["unload", "-w", path], { stdio: "ignore" });
83
+ p.on("error", () => resolve());
84
+ p.on("exit", () => resolve());
85
+ });
86
+ }
87
+ await rm(path, { force: true }).catch(() => { });
88
+ }
89
+ // ---- Linux: XDG autostart --------------------------------------------------
90
+ function linuxDesktopPath() {
91
+ const cfg = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
92
+ return join(cfg, "autostart", "data-loom.desktop");
93
+ }
94
+ async function linuxEnable() {
95
+ await mkdir(dirname(linuxDesktopPath()), { recursive: true });
96
+ const entry = `[Desktop Entry]
97
+ Type=Application
98
+ Name=DataLoom
99
+ Comment=DataLoom background daemon
100
+ Exec="${NODE}" "${SCRIPT}" start
101
+ X-GNOME-Autostart-enabled=true
102
+ `;
103
+ await writeFile(linuxDesktopPath(), entry, "utf8");
104
+ }
105
+ async function linuxDisable() {
106
+ await rm(linuxDesktopPath(), { force: true }).catch(() => { });
107
+ }
108
+ // ---- Dispatch --------------------------------------------------------------
109
+ function unsupported() {
110
+ throw new Error(`[data-loom] autostart is not supported on platform "${process.platform}"`);
111
+ }
112
+ /** Register the daemon to launch at login (idempotent — overwrites any prior). */
113
+ export async function enable() {
114
+ if (process.platform === "win32")
115
+ return winEnable();
116
+ if (process.platform === "darwin")
117
+ return macEnable();
118
+ if (process.platform === "linux")
119
+ return linuxEnable();
120
+ unsupported();
121
+ }
122
+ /** Remove the login registration (idempotent — succeeds when not enabled). */
123
+ export async function disable() {
124
+ if (process.platform === "win32")
125
+ return winDisable();
126
+ if (process.platform === "darwin")
127
+ return macDisable();
128
+ if (process.platform === "linux")
129
+ return linuxDisable();
130
+ unsupported();
131
+ }
132
+ /** True iff a login registration currently exists for this user. */
133
+ export async function isEnabled() {
134
+ if (process.platform === "win32")
135
+ return exists(winStartupLnk());
136
+ if (process.platform === "darwin")
137
+ return exists(macPlistPath());
138
+ if (process.platform === "linux")
139
+ return exists(linuxDesktopPath());
140
+ return false;
141
+ }
@@ -0,0 +1,82 @@
1
+ // Claude Desktop integration: register DataLoom's loopback MCP endpoint into
2
+ // Claude Desktop's claude_desktop_config.json so the same running daemon that
3
+ // serves Claude Code also serves Claude Desktop.
4
+ //
5
+ // By default we write the native remote/HTTP form (a URL entry) — no extra
6
+ // process, one daemon as the single source of truth. Claude Desktop versions
7
+ // without native remote support use the stdio bridge (`npx mcp-remote <url>`),
8
+ // which the caller forces with `--bridge`. Either way the daemon needs no change:
9
+ // it already permits native (no-Origin) MCP clients on loopback.
10
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ import { mcpUrl } from "./paths.js";
14
+ const KEY = "data-loom";
15
+ /** Per-OS location of Claude Desktop's config file. */
16
+ export function configPath() {
17
+ if (process.platform === "win32") {
18
+ const appData = process.env.APPDATA ?? join(homedir(), "AppData", "Roaming");
19
+ return join(appData, "Claude", "claude_desktop_config.json");
20
+ }
21
+ if (process.platform === "darwin") {
22
+ return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
23
+ }
24
+ const cfg = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
25
+ return join(cfg, "Claude", "claude_desktop_config.json");
26
+ }
27
+ /** Read + parse the config, or return {} when absent. Throws on malformed JSON. */
28
+ async function readConfig(path) {
29
+ let raw;
30
+ try {
31
+ raw = await readFile(path, "utf8");
32
+ }
33
+ catch {
34
+ return {};
35
+ }
36
+ if (!raw.trim())
37
+ return {};
38
+ try {
39
+ const parsed = JSON.parse(raw);
40
+ if (typeof parsed !== "object" || parsed === null) {
41
+ throw new Error("not a JSON object");
42
+ }
43
+ return parsed;
44
+ }
45
+ catch (e) {
46
+ throw new Error(`[data-loom] ${path} is not valid JSON (${e instanceof Error ? e.message : "parse error"}); ` +
47
+ "fix or remove it, then retry — DataLoom will not overwrite it.");
48
+ }
49
+ }
50
+ async function writeConfig(path, cfg) {
51
+ await mkdir(dirname(path), { recursive: true });
52
+ await writeFile(path, JSON.stringify(cfg, null, 2) + "\n", "utf8");
53
+ }
54
+ /** The MCP entry we register — native URL form by default, stdio bridge on demand. */
55
+ function entry(bridge) {
56
+ const url = mcpUrl();
57
+ return bridge ? { command: "npx", args: ["-y", "mcp-remote", url] } : { type: "http", url };
58
+ }
59
+ /**
60
+ * Add/update DataLoom's entry under mcpServers, preserving everything else.
61
+ * Returns the path written so the caller can report it.
62
+ */
63
+ export async function connect(opts = {}) {
64
+ const path = configPath();
65
+ const cfg = await readConfig(path);
66
+ cfg.mcpServers = { ...(cfg.mcpServers ?? {}), [KEY]: entry(opts.bridge ?? false) };
67
+ await writeConfig(path, cfg);
68
+ return path;
69
+ }
70
+ /**
71
+ * Remove only DataLoom's entry. Idempotent — returns false when nothing was
72
+ * registered (so the caller can say "nothing to remove").
73
+ */
74
+ export async function disconnect() {
75
+ const path = configPath();
76
+ const cfg = await readConfig(path);
77
+ if (!cfg.mcpServers || !(KEY in cfg.mcpServers))
78
+ return { path, removed: false };
79
+ delete cfg.mcpServers[KEY];
80
+ await writeConfig(path, cfg);
81
+ return { path, removed: true };
82
+ }
package/dist/index.js CHANGED
@@ -12,8 +12,12 @@ import { discover } from "./mcp/discovery.js";
12
12
  import { checkServer } from "./mcp/availability.js";
13
13
  import { discoverProjects, isViewableProject } from "./projects.js";
14
14
  import { resolvePublicDir } from "./assets.js";
15
- const host = "127.0.0.1";
16
- const port = Number(process.env.PORT ?? 4317);
15
+ import { HOST, PORT } from "./paths.js";
16
+ import * as lifecycle from "./lifecycle.js";
17
+ import * as autostart from "./autostart.js";
18
+ import * as claudeDesktop from "./claudeDesktop.js";
19
+ const host = HOST;
20
+ const port = PORT;
17
21
  // Initial project: CLI argument, then DATA_LOOM_ROOT, then cwd.
18
22
  const initialProject = resolve(process.argv[2] ?? process.env.DATA_LOOM_ROOT ?? process.cwd());
19
23
  let session;
@@ -124,7 +128,9 @@ function getProjects() {
124
128
  return discoverProjects(session?.project ?? "");
125
129
  }
126
130
  function openBrowser(url) {
127
- if (process.env.DATA_LOOM_NO_OPEN)
131
+ // Detached background runs have no attached console and no user waiting at the
132
+ // terminal, so never pop a browser for them (nor when explicitly suppressed).
133
+ if (process.env.DATA_LOOM_NO_OPEN || process.env.DATA_LOOM_DETACHED)
128
134
  return;
129
135
  try {
130
136
  if (process.platform === "win32") {
@@ -141,7 +147,82 @@ function openBrowser(url) {
141
147
  /* non-fatal — the URL is already logged */
142
148
  }
143
149
  }
144
- main().catch((err) => {
145
- console.error(err);
146
- process.exit(1);
147
- });
150
+ // ---- CLI verb dispatch -----------------------------------------------------
151
+ // A leading reserved verb selects a lifecycle/autostart/integration command;
152
+ // anything else falls through to the foreground daemon (with argv[2] as the
153
+ // optional project path), preserving the original invocation unchanged.
154
+ const VERBS = new Set(["start", "stop", "restart", "status", "autostart", "connect", "disconnect"]);
155
+ async function runAutostart(rest) {
156
+ const sub = rest[0];
157
+ if (sub === "enable") {
158
+ await autostart.enable();
159
+ console.log("[data-loom] autostart enabled (launches on login)");
160
+ // Enabling also brings the daemon up now, so it's running this session too —
161
+ // opt out with --no-start.
162
+ if (!rest.includes("--no-start"))
163
+ await lifecycle.start();
164
+ return;
165
+ }
166
+ if (sub === "disable") {
167
+ await autostart.disable();
168
+ console.log("[data-loom] autostart disabled");
169
+ return;
170
+ }
171
+ if (sub === "status") {
172
+ console.log((await autostart.isEnabled())
173
+ ? "[data-loom] autostart is enabled"
174
+ : "[data-loom] autostart is not enabled");
175
+ return;
176
+ }
177
+ throw new Error("usage: data-loom autostart <enable|disable|status> [--no-start]");
178
+ }
179
+ async function runConnect(rest) {
180
+ if (rest[0] !== "claude-desktop")
181
+ throw new Error("usage: data-loom connect claude-desktop [--bridge]");
182
+ const bridge = rest.includes("--bridge");
183
+ const path = await claudeDesktop.connect({ bridge });
184
+ console.log(`[data-loom] registered DataLoom in Claude Desktop (${bridge ? "stdio bridge" : "native HTTP"}) — ${path}`);
185
+ console.log("[data-loom] restart Claude Desktop to pick it up; DataLoom must be running to serve the tools.");
186
+ }
187
+ async function runDisconnect(rest) {
188
+ if (rest[0] !== "claude-desktop")
189
+ throw new Error("usage: data-loom disconnect claude-desktop");
190
+ const { path, removed } = await claudeDesktop.disconnect();
191
+ console.log(removed
192
+ ? `[data-loom] removed DataLoom from Claude Desktop — ${path}`
193
+ : "[data-loom] DataLoom was not registered in Claude Desktop — nothing to remove");
194
+ }
195
+ async function runCli(argv) {
196
+ const [verb, ...rest] = argv;
197
+ switch (verb) {
198
+ case "start":
199
+ return lifecycle.start(rest[0]);
200
+ case "stop":
201
+ return lifecycle.stop();
202
+ case "restart":
203
+ return lifecycle.restart(rest[0]);
204
+ case "status":
205
+ return lifecycle.status();
206
+ case "autostart":
207
+ return runAutostart(rest);
208
+ case "connect":
209
+ return runConnect(rest);
210
+ case "disconnect":
211
+ return runDisconnect(rest);
212
+ default:
213
+ throw new Error(`unknown command: ${verb}`);
214
+ }
215
+ }
216
+ const cliArgs = process.argv.slice(2);
217
+ if (cliArgs[0] && VERBS.has(cliArgs[0])) {
218
+ runCli(cliArgs).catch((err) => {
219
+ console.error(err instanceof Error ? err.message : err);
220
+ process.exit(1);
221
+ });
222
+ }
223
+ else {
224
+ main().catch((err) => {
225
+ console.error(err);
226
+ process.exit(1);
227
+ });
228
+ }
@@ -0,0 +1,101 @@
1
+ // Background lifecycle for the daemon: start it detached, stop it, restart it,
2
+ // and report status. The loopback port is the authoritative "is it running?"
3
+ // signal — a stale PID file never blocks a start, because the source of truth is
4
+ // whether something actually answers on the port.
5
+ import { spawn } from "node:child_process";
6
+ import { openSync } from "node:fs";
7
+ import { readFile, writeFile, rm } from "node:fs/promises";
8
+ import { get } from "node:http";
9
+ import { HOST, PORT, baseUrl, logFile, pidFile, ensureStateDir } from "./paths.js";
10
+ /** Probe the loopback endpoint; true iff a daemon answers within the timeout. */
11
+ export function isRunning() {
12
+ return new Promise((resolve) => {
13
+ const req = get({ host: HOST, port: PORT, path: "/api/model", timeout: 1000 }, (res) => {
14
+ res.resume(); // drain
15
+ resolve(true);
16
+ });
17
+ req.on("error", () => resolve(false));
18
+ req.on("timeout", () => {
19
+ req.destroy();
20
+ resolve(false);
21
+ });
22
+ });
23
+ }
24
+ async function readPid() {
25
+ try {
26
+ const n = Number((await readFile(pidFile(), "utf8")).trim());
27
+ return Number.isInteger(n) && n > 0 ? n : undefined;
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
34
+ /**
35
+ * Start the daemon detached from this terminal. If one already answers on the
36
+ * port, this is a reported no-op (single-instance guard). The child re-runs this
37
+ * same entry point in the foreground with DATA_LOOM_DETACHED set so it skips the
38
+ * browser and logs to the state-dir log file.
39
+ */
40
+ export async function start(project) {
41
+ if (await isRunning()) {
42
+ console.log(`[data-loom] already running — ${baseUrl()}`);
43
+ return;
44
+ }
45
+ await ensureStateDir();
46
+ const out = openSync(logFile(), "a");
47
+ const args = [process.argv[1], ...(project ? [project] : [])];
48
+ const child = spawn(process.execPath, args, {
49
+ detached: true,
50
+ windowsHide: true,
51
+ stdio: ["ignore", out, out],
52
+ env: { ...process.env, DATA_LOOM_DETACHED: "1" },
53
+ });
54
+ child.unref();
55
+ if (child.pid)
56
+ await writeFile(pidFile(), String(child.pid), "utf8");
57
+ console.log(`[data-loom] started in background (pid ${child.pid ?? "?"}) — ${baseUrl()}`);
58
+ console.log(`[data-loom] logs: ${logFile()}`);
59
+ }
60
+ /** Stop the background daemon; a no-op (with notice) when nothing is running. */
61
+ export async function stop() {
62
+ const pid = await readPid();
63
+ const running = await isRunning();
64
+ if (!running && pid === undefined) {
65
+ console.log("[data-loom] not running — nothing to stop");
66
+ return;
67
+ }
68
+ if (pid !== undefined) {
69
+ try {
70
+ process.kill(pid, "SIGTERM");
71
+ }
72
+ catch {
73
+ /* already gone */
74
+ }
75
+ }
76
+ else if (running) {
77
+ console.error("[data-loom] a daemon is answering the port but no PID file was found; stop it manually");
78
+ return;
79
+ }
80
+ // Wait for the port to actually free (up to ~5s), then drop the PID file.
81
+ for (let i = 0; i < 25 && (await isRunning()); i++)
82
+ await delay(200);
83
+ await rm(pidFile(), { force: true }).catch(() => { });
84
+ console.log("[data-loom] stopped");
85
+ }
86
+ /** Restart: stop any running instance, then start a fresh one. */
87
+ export async function restart(project) {
88
+ await stop();
89
+ await start(project);
90
+ }
91
+ /** Report whether the daemon is running, and where to reach it / find its logs. */
92
+ export async function status() {
93
+ if (await isRunning()) {
94
+ const pid = await readPid();
95
+ console.log(`[data-loom] running — ${baseUrl()}${pid !== undefined ? ` (pid ${pid})` : ""}`);
96
+ console.log(`[data-loom] logs: ${logFile()}`);
97
+ }
98
+ else {
99
+ console.log("[data-loom] not running");
100
+ }
101
+ }
package/dist/mcpServer.js CHANGED
@@ -15,6 +15,14 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
15
15
  import { OpenSpecClient } from "./openspecClient.js";
16
16
  import { deriveModel } from "./derive.js";
17
17
  import { discoverProjects, isViewableProject } from "./projects.js";
18
+ /**
19
+ * A validation error whose message is safe to return to the client because it
20
+ * only references caller-supplied input (a change name, a path the caller named).
21
+ * Anything that is NOT a ToolError is treated as an unexpected internal failure
22
+ * and reported generically, so host filesystem detail never leaks.
23
+ */
24
+ class ToolError extends Error {
25
+ }
18
26
  // Advertised to the client on connect. The "confirm before writing" gate lives
19
27
  // here, in the agent's behavior — the server cannot verify a human approved.
20
28
  const INSTRUCTIONS = `This server exposes a project's open OpenSpec proposals and lets you record the dependency order between them. It holds no model and cannot infer anything on its own — the judgment is yours and the decision is the user's.
@@ -67,18 +75,18 @@ const PROJECT_ARG = {
67
75
  * the dashboard selection is the single fallback source of project truth.
68
76
  */
69
77
  export function createMcpServer(deps) {
70
- const server = new Server({ name: "data-loom", version: "0.4.0" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
78
+ const server = new Server({ name: "data-loom", version: "0.4.1" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
71
79
  // Resolve the target project for a call: explicit arg, then dashboard
72
80
  // selection, then an instructive error. Validated as a real workspace before
73
81
  // any read or write.
74
82
  const resolveProject = (explicit) => {
75
83
  const raw = typeof explicit === "string" && explicit.trim() ? explicit : deps.getCurrentProject();
76
84
  if (!raw) {
77
- throw new Error("No `project` given and no project is selected in the DataLoom dashboard. Call list_projects to see the available OpenSpec workspaces, then pass one as the `project` argument.");
85
+ throw new ToolError("No `project` given and no project is selected in the DataLoom dashboard. Call list_projects to see the available OpenSpec workspaces, then pass one as the `project` argument.");
78
86
  }
79
87
  const abs = resolve(raw);
80
88
  if (!isViewableProject(abs)) {
81
- throw new Error(`${abs} is not an OpenSpec workspace (no openspec/ directory). Call list_projects to see the available workspaces.`);
89
+ throw new ToolError(`${abs} is not an OpenSpec workspace (no openspec/ directory). Call list_projects to see the available workspaces.`);
82
90
  }
83
91
  return abs;
84
92
  };
@@ -161,7 +169,12 @@ export function createMcpServer(deps) {
161
169
  return err(`unknown tool: ${name}`);
162
170
  }
163
171
  catch (e) {
164
- return err(e instanceof Error ? e.message : String(e));
172
+ // Validation errors (caller-supplied input) are safe to surface; anything
173
+ // else is an unexpected internal failure → generic message, detail to log.
174
+ if (e instanceof ToolError)
175
+ return err(e.message);
176
+ console.error("[data-loom mcp] tool error:", e);
177
+ return err("internal error");
165
178
  }
166
179
  });
167
180
  return server;
@@ -202,14 +215,14 @@ async function listOpenProposals(client, changesDir, project) {
202
215
  }
203
216
  async function setDependency(client, changesDir, project, from, to) {
204
217
  if (!from || !to)
205
- throw new Error("both 'from' and 'to' are required");
218
+ throw new ToolError("both 'from' and 'to' are required");
206
219
  if (from === to)
207
- throw new Error("a change cannot depend on itself");
220
+ throw new ToolError("a change cannot depend on itself");
208
221
  const names = new Set((await openChanges(client)).map((c) => c.name));
209
222
  if (!names.has(from))
210
- throw new Error(`unknown open change: ${from}`);
223
+ throw new ToolError(`unknown open change: ${from}`);
211
224
  if (!names.has(to))
212
- throw new Error(`unknown open change: ${to}`);
225
+ throw new ToolError(`unknown open change: ${to}`);
213
226
  const path = join(changesDir, from, "proposal.md");
214
227
  const text = await readFile(path, "utf8");
215
228
  const updated = addDependsOn(text, to);
@@ -220,10 +233,10 @@ async function setDependency(client, changesDir, project, from, to) {
220
233
  }
221
234
  async function markIndependent(client, changesDir, project, change) {
222
235
  if (!change)
223
- throw new Error("'change' is required");
236
+ throw new ToolError("'change' is required");
224
237
  const names = new Set((await openChanges(client)).map((c) => c.name));
225
238
  if (!names.has(change))
226
- throw new Error(`unknown open change: ${change}`);
239
+ throw new ToolError(`unknown open change: ${change}`);
227
240
  const path = join(changesDir, change, "proposal.md");
228
241
  const text = await readFile(path, "utf8");
229
242
  const updated = ensureDependsOnSection(text);
package/dist/paths.js ADDED
@@ -0,0 +1,39 @@
1
+ // Shared config + per-user state locations for the daemon and its CLI verbs.
2
+ // The lifecycle commands (start/stop/status), autostart registration, and the
3
+ // Claude Desktop integration all need to agree on the loopback address, the
4
+ // state directory, and the PID/log paths — so they live here, in one place.
5
+ import { mkdir } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ /** Loopback host + port the daemon binds. Port overridable via env for tests. */
9
+ export const HOST = "127.0.0.1";
10
+ export const PORT = Number(process.env.PORT ?? 4317);
11
+ /** Dashboard base URL and the MCP endpoint hosted on the same authority. */
12
+ export const baseUrl = () => `http://${HOST}:${PORT}`;
13
+ export const mcpUrl = () => `${baseUrl()}/mcp`;
14
+ /**
15
+ * Per-user state directory, using the OS convention:
16
+ * Windows %LOCALAPPDATA%\data-loom
17
+ * macOS ~/Library/Application Support/data-loom
18
+ * Linux ${XDG_STATE_HOME:-~/.local/state}/data-loom
19
+ * Holds the PID file and the background log — never the project dir.
20
+ */
21
+ export function stateDir() {
22
+ if (process.platform === "win32") {
23
+ const local = process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local");
24
+ return join(local, "data-loom");
25
+ }
26
+ if (process.platform === "darwin") {
27
+ return join(homedir(), "Library", "Application Support", "data-loom");
28
+ }
29
+ const xdg = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
30
+ return join(xdg, "data-loom");
31
+ }
32
+ export const pidFile = () => join(stateDir(), "daemon.pid");
33
+ export const logFile = () => join(stateDir(), "daemon.log");
34
+ /** Create the state dir on demand; safe to call repeatedly. */
35
+ export async function ensureStateDir() {
36
+ const dir = stateDir();
37
+ await mkdir(dir, { recursive: true });
38
+ return dir;
39
+ }
package/dist/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Serve the SPA on loopback and push roadmap + MCP + project state over a websocket.
2
2
  import { createServer } from "node:http";
3
3
  import { randomUUID } from "node:crypto";
4
- import { extname } from "node:path";
4
+ import { extname, resolve, sep } from "node:path";
5
5
  import { WebSocketServer, WebSocket } from "ws";
6
6
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
7
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
@@ -24,53 +24,110 @@ export async function startServer(opts) {
24
24
  ws.send(data);
25
25
  }
26
26
  };
27
+ // Loopback origin/host allowlist — the DNS-rebinding + CSRF guard. Built from
28
+ // the actually-bound port after listen; seeded from the requested port so the
29
+ // guard is active from the first request.
30
+ const loopbackHosts = ["127.0.0.1", "localhost", "[::1]"];
31
+ let allowedAuthorities = new Set();
32
+ const setAllowed = (p) => {
33
+ allowedAuthorities = new Set(loopbackHosts.map((h) => `${h}:${p}`));
34
+ };
35
+ setAllowed(port);
36
+ // Reject what a hostile web page or a rebound DNS name could mount: a Host
37
+ // that is not our loopback authority (DNS rebinding), or any *present* Origin
38
+ // that is not a loopback origin (CSRF). An absent Origin = a native, non-
39
+ // browser client (e.g. Claude Code), which is allowed.
40
+ const isAllowed = (req) => {
41
+ const host = req.headers.host;
42
+ if (!host || !allowedAuthorities.has(host))
43
+ return false;
44
+ const origin = req.headers.origin;
45
+ if (origin) {
46
+ try {
47
+ if (!allowedAuthorities.has(new URL(origin).host))
48
+ return false;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ return true;
55
+ };
27
56
  // One MCP server + transport per client session (stateful Streamable-HTTP),
28
- // keyed by the session id. Each session's tools resolve their target project
29
- // per call from getCurrentProject (see mcpServer.ts), so one daemon serves
30
- // every project.
31
- const mcpTransports = new Map();
57
+ // keyed by session id. Sessions are capped and idle-evicted so churn cannot
58
+ // grow memory without bound. Tools resolve their target project per call from
59
+ // getCurrentProject (see mcpServer.ts), so one daemon serves every project.
60
+ const MAX_SESSIONS = 32;
61
+ const SESSION_IDLE_TTL_MS = 30 * 60 * 1000;
62
+ const mcpSessions = new Map();
63
+ const idleSweep = setInterval(() => {
64
+ const now = Date.now();
65
+ for (const [sid, s] of mcpSessions) {
66
+ if (now - s.lastSeen > SESSION_IDLE_TTL_MS) {
67
+ mcpSessions.delete(sid);
68
+ void s.transport.close();
69
+ }
70
+ }
71
+ }, 60 * 1000);
72
+ idleSweep.unref();
32
73
  const handleMcp = async (req, res) => {
33
74
  const sessionId = req.headers["mcp-session-id"];
34
75
  try {
35
76
  if (req.method === "POST") {
36
77
  const body = await readJsonBody(req);
37
- let transport = sessionId ? mcpTransports.get(sessionId) : undefined;
38
- if (!transport) {
39
- if (!isInitializeRequest(body)) {
40
- return sendError(res, 400, "missing or invalid mcp-session-id");
41
- }
42
- transport = new StreamableHTTPServerTransport({
43
- sessionIdGenerator: () => randomUUID(),
44
- onsessioninitialized: (sid) => {
45
- mcpTransports.set(sid, transport);
46
- },
47
- });
48
- transport.onclose = () => {
49
- if (transport.sessionId)
50
- mcpTransports.delete(transport.sessionId);
51
- };
52
- await createMcpServer({ getCurrentProject }).connect(transport);
78
+ const existing = sessionId ? mcpSessions.get(sessionId) : undefined;
79
+ if (existing) {
80
+ existing.lastSeen = Date.now();
81
+ await existing.transport.handleRequest(req, res, body);
82
+ return;
83
+ }
84
+ if (!isInitializeRequest(body)) {
85
+ return sendError(res, 400, "missing or invalid mcp-session-id");
53
86
  }
87
+ if (mcpSessions.size >= MAX_SESSIONS) {
88
+ return sendError(res, 503, "too many sessions");
89
+ }
90
+ const transport = new StreamableHTTPServerTransport({
91
+ sessionIdGenerator: () => randomUUID(),
92
+ onsessioninitialized: (sid) => {
93
+ mcpSessions.set(sid, { transport, lastSeen: Date.now() });
94
+ },
95
+ });
96
+ transport.onclose = () => {
97
+ if (transport.sessionId)
98
+ mcpSessions.delete(transport.sessionId);
99
+ };
100
+ await createMcpServer({ getCurrentProject }).connect(transport);
54
101
  await transport.handleRequest(req, res, body);
55
102
  return;
56
103
  }
57
104
  if (req.method === "GET" || req.method === "DELETE") {
58
- const transport = sessionId ? mcpTransports.get(sessionId) : undefined;
59
- if (!transport)
105
+ const s = sessionId ? mcpSessions.get(sessionId) : undefined;
106
+ if (!s)
60
107
  return sendError(res, 400, "missing or invalid mcp-session-id");
61
- await transport.handleRequest(req, res);
108
+ s.lastSeen = Date.now();
109
+ await s.transport.handleRequest(req, res);
62
110
  return;
63
111
  }
64
112
  return sendError(res, 405, "method not allowed");
65
113
  }
66
114
  catch (e) {
67
- if (!res.headersSent) {
68
- sendError(res, 500, e instanceof Error ? e.message : "mcp error");
115
+ if (e instanceof PayloadTooLargeError) {
116
+ if (!res.headersSent)
117
+ sendError(res, 413, "request body too large");
118
+ return;
69
119
  }
120
+ // Unexpected failures stay in the server log; the client gets nothing
121
+ // about the host (no paths, no stack).
122
+ console.error("[data-loom] mcp request error:", e);
123
+ if (!res.headersSent)
124
+ sendError(res, 500, "internal error");
70
125
  }
71
126
  };
72
127
  const http = createServer(async (req, res) => {
73
128
  try {
129
+ if (!isAllowed(req))
130
+ return sendError(res, 403, "forbidden");
74
131
  const url = new URL(req.url ?? "/", `http://${host}`);
75
132
  if (url.pathname === "/mcp")
76
133
  return handleMcp(req, res);
@@ -96,9 +153,12 @@ export async function startServer(opts) {
96
153
  return sendError(res, 400, err instanceof Error ? err.message : "invalid project");
97
154
  }
98
155
  }
99
- // static assets (served from public/ on the filesystem)
156
+ // static assets (served from public/ on the filesystem) — resolve and
157
+ // assert the path stays within publicDir before reading.
100
158
  const rel = url.pathname === "/" ? "index.html" : url.pathname.replace(/^\/+/, "");
101
- if (rel.includes(".."))
159
+ const root = resolve(publicDir);
160
+ const full = resolve(root, rel);
161
+ if (full !== root && !full.startsWith(root + sep))
102
162
  return sendError(res, 403, "forbidden");
103
163
  const file = await loadAsset(rel, publicDir);
104
164
  if (!file)
@@ -110,7 +170,10 @@ export async function startServer(opts) {
110
170
  sendError(res, 500, "error");
111
171
  }
112
172
  });
113
- wss = new WebSocketServer({ server: http });
173
+ wss = new WebSocketServer({
174
+ server: http,
175
+ verifyClient: (info) => isAllowed(info.req),
176
+ });
114
177
  wss.on("connection", async (ws) => {
115
178
  const roadmap = getRoadmap();
116
179
  if (roadmap)
@@ -123,11 +186,14 @@ export async function startServer(opts) {
123
186
  /* project list optional */
124
187
  }
125
188
  });
126
- await new Promise((resolve) => http.listen(port, host, resolve));
189
+ await new Promise((ready) => http.listen(port, host, ready));
190
+ const actualPort = http.address()?.port ?? port;
191
+ setAllowed(actualPort);
127
192
  return {
128
- port,
193
+ port: actualPort,
129
194
  broadcast,
130
195
  close: () => {
196
+ clearInterval(idleSweep);
131
197
  wss.close();
132
198
  http.close();
133
199
  },
@@ -141,12 +207,32 @@ function sendError(res, code, message) {
141
207
  res.writeHead(code, { "content-type": MIME[".json"] });
142
208
  res.end(JSON.stringify({ error: message }));
143
209
  }
210
+ /** Raised when a request body exceeds the cap; mapped to HTTP 413. */
211
+ class PayloadTooLargeError extends Error {
212
+ }
213
+ /** Largest request body we will buffer (JSON-RPC tool calls are tiny). */
214
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
144
215
  /** Read and JSON-parse a request body; returns undefined for an empty body. */
145
216
  function readJsonBody(req) {
146
217
  return new Promise((resolve, reject) => {
147
218
  let data = "";
148
- req.on("data", (chunk) => (data += chunk));
219
+ let size = 0;
220
+ let aborted = false;
221
+ req.on("data", (chunk) => {
222
+ if (aborted)
223
+ return;
224
+ size += chunk.length;
225
+ if (size > MAX_BODY_BYTES) {
226
+ aborted = true;
227
+ reject(new PayloadTooLargeError("request body too large"));
228
+ req.destroy();
229
+ return;
230
+ }
231
+ data += chunk;
232
+ });
149
233
  req.on("end", () => {
234
+ if (aborted)
235
+ return;
150
236
  if (!data)
151
237
  return resolve(undefined);
152
238
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyric_dev/data-loom",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Local dashboard for spec-driven development: a phased OpenSpec roadmap (WHAT) and an MCP topology (HOW).",
5
5
  "type": "module",
6
6
  "license": "MIT",