@lyric_dev/data-loom 0.4.1 → 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.
@@ -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/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyric_dev/data-loom",
3
- "version": "0.4.1",
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",