@lyric_dev/data-loom 0.4.1 → 0.6.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 **and registers DataLoom with Claude Code** (the same as `data-loom connect claude-code`), so the always-on path both hosts the MCP endpoint and points Claude Code at it in one command. Pass `--no-start` to only register for next login, or `--no-connect` to skip the Claude Code registration. The Claude Code step is best-effort — if the `claude` CLI isn't found, `enable` warns and still sets up the login item and daemon. 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`, `data-loom disconnect claude-code`, 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.
@@ -60,13 +85,29 @@ npm start # serves the current directory's project
60
85
 
61
86
  The running daemon also **hosts an MCP server** over HTTP, so your own Claude session determines and applies the order of interdependent proposals — DataLoom holds no API key and the reasoning runs under your authenticated Claude. One registration serves **every** project; the target project is resolved per call (an explicit `project` argument, falling back to whatever the dashboard has selected).
62
87
 
63
- 1. Register it once, globally — no per-project setup:
88
+ 1. Register it once, globally — no per-project setup. The easy way:
89
+
90
+ ```
91
+ data-loom connect claude-code
92
+ ```
93
+
94
+ This registers DataLoom's loopback endpoint with Claude Code at user scope via Claude Code's own CLI (it runs `claude mcp add` for you; DataLoom never edits `~/.claude.json` itself). Start a new Claude Code session to pick it up, and remove it any time with `data-loom disconnect claude-code`. If the `claude` CLI isn't on your PATH, the command prints the manual line to run instead:
64
95
 
65
96
  ```
66
97
  claude mcp add --transport http --scope user data-loom http://127.0.0.1:4317/mcp
67
98
  ```
68
99
 
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.
100
+ Either way, if you enable always-on autostart (below), `data-loom autostart enable` already runs this registration for you so a fresh install can be one command.
101
+
102
+ 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.
103
+
104
+ **Claude Desktop** reaches the same daemon — register it with:
105
+
106
+ ```
107
+ data-loom connect claude-desktop
108
+ ```
109
+
110
+ 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
111
 
71
112
  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
113
  - `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,81 @@
1
+ // Claude Code integration: register DataLoom's loopback MCP endpoint with
2
+ // Claude Code at user (global) scope, so the same running daemon that serves
3
+ // the dashboard also serves a Claude Code session.
4
+ //
5
+ // Unlike the Claude Desktop integration (which edits a small, DataLoom-owned
6
+ // config file directly), this registers through Claude Code's OWN CLI
7
+ // (`claude mcp add`/`remove`). DataLoom never reads or writes `~/.claude.json`
8
+ // itself — that file holds OAuth tokens and per-project history and is written
9
+ // live by any running session, so its one owner (the `claude` CLI) serializes
10
+ // the write. When the `claude` CLI is missing, connect degrades to printing the
11
+ // manual command rather than failing hard.
12
+ import { execFile } from "node:child_process";
13
+ import { promisify } from "node:util";
14
+ import { mcpUrl } from "./paths.js";
15
+ const execFileP = promisify(execFile);
16
+ const KEY = "data-loom";
17
+ /** On Windows the CLI is a `.cmd`/`.ps1` shim, so it must be invoked through a shell. */
18
+ function runClaude(args) {
19
+ return execFileP("claude", args, { shell: process.platform === "win32" });
20
+ }
21
+ /** The registration command a user can run by hand when the `claude` CLI is absent. */
22
+ export function manualCommand() {
23
+ return `claude mcp add --transport http --scope user ${KEY} ${mcpUrl()}`;
24
+ }
25
+ /** True iff the `claude` CLI is invocable (resolves its version). */
26
+ async function claudeAvailable() {
27
+ try {
28
+ await runClaude(["--version"]);
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ /** Remove any existing user-scope registration; tolerates "not registered". */
36
+ async function removeRegistration() {
37
+ try {
38
+ await runClaude(["mcp", "remove", "--scope", "user", KEY]);
39
+ }
40
+ catch {
41
+ /* not registered (or nothing to remove) — idempotent no-op */
42
+ }
43
+ }
44
+ /**
45
+ * Register DataLoom with Claude Code at user scope, pointing at the daemon's
46
+ * loopback HTTP MCP endpoint. Idempotent upsert: remove any prior entry, then
47
+ * add, so exactly one current entry remains. Returns false (with printed
48
+ * guidance) when the `claude` CLI is unavailable, so callers can degrade
49
+ * gracefully instead of failing.
50
+ */
51
+ export async function connect() {
52
+ if (!(await claudeAvailable())) {
53
+ console.log("[data-loom] the `claude` CLI was not found — cannot register automatically.\n" +
54
+ `[data-loom] register it by hand once with:\n ${manualCommand()}`);
55
+ return false;
56
+ }
57
+ await removeRegistration();
58
+ await runClaude(["mcp", "add", "--transport", "http", "--scope", "user", KEY, mcpUrl()]);
59
+ return true;
60
+ }
61
+ /**
62
+ * Remove DataLoom's user-scope registration from Claude Code. Idempotent —
63
+ * reports whether anything was actually registered. Throws only if the `claude`
64
+ * CLI itself is unavailable (nothing could have been registered without it).
65
+ */
66
+ export async function disconnect() {
67
+ if (!(await claudeAvailable())) {
68
+ throw new Error("the `claude` CLI was not found — nothing could have been registered");
69
+ }
70
+ // `claude mcp get <name>` exits non-zero when the entry is absent; use it to
71
+ // report removed-vs-nothing without parsing `list` output.
72
+ let existed = true;
73
+ try {
74
+ await runClaude(["mcp", "get", KEY]);
75
+ }
76
+ catch {
77
+ existed = false;
78
+ }
79
+ await removeRegistration();
80
+ return { removed: existed };
81
+ }
@@ -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,14 @@ 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
+ import * as claudeCode from "./claudeCode.js";
20
+ import { initTray } from "./tray.js";
21
+ const host = HOST;
22
+ const port = PORT;
17
23
  // Initial project: CLI argument, then DATA_LOOM_ROOT, then cwd.
18
24
  const initialProject = resolve(process.argv[2] ?? process.env.DATA_LOOM_ROOT ?? process.cwd());
19
25
  let session;
@@ -42,28 +48,53 @@ async function main() {
42
48
  else {
43
49
  console.log("[data-loom] no openspec project found at launch — open the dashboard and pick one");
44
50
  }
45
- server = await startServer({
46
- publicDir: resolvePublicDir(),
47
- host,
48
- port,
49
- getRoadmap: () => session?.model ?? null,
50
- getMcp,
51
- checkMcp,
52
- getProjects,
53
- selectProject,
54
- getCurrentProject: () => session?.project ?? null,
55
- });
56
- console.log(`[data-loom] dashboard ready at http://${host}:${server.port}`);
51
+ try {
52
+ server = await startServer({
53
+ publicDir: resolvePublicDir(),
54
+ host,
55
+ port,
56
+ getRoadmap: () => session?.model ?? null,
57
+ getMcp,
58
+ checkMcp,
59
+ getProjects,
60
+ selectProject,
61
+ getCurrentProject: () => session?.project ?? null,
62
+ });
63
+ }
64
+ catch (err) {
65
+ if (err.code === "EADDRINUSE") {
66
+ // A DataLoom daemon is already on this port (e.g. the background one).
67
+ // That's fine — point at the running instance instead of crashing.
68
+ console.log(`[data-loom] already running at http://${host}:${port} — using that instance.`);
69
+ openBrowser(`http://${host}:${port}`);
70
+ session?.stopWatch(); // release the watcher started for this aborted launch
71
+ return;
72
+ }
73
+ throw err;
74
+ }
75
+ const url = `http://${host}:${server.port}`;
76
+ console.log(`[data-loom] dashboard ready at ${url}`);
57
77
  if (session)
58
78
  console.log(`[data-loom] project: ${session.project}`);
59
- openBrowser(`http://${host}:${server.port}`);
79
+ openBrowser(url);
80
+ // Tray icon: an ambient "DataLoom is running" indicator (essential in the
81
+ // detached mode, which has no console). Guarded no-op where unavailable.
82
+ let tray = { dispose: () => { } };
60
83
  const shutdown = () => {
84
+ tray.dispose();
61
85
  session?.stopWatch();
62
86
  server?.close();
63
87
  process.exit(0);
64
88
  };
65
89
  process.on("SIGINT", shutdown);
66
90
  process.on("SIGTERM", shutdown);
91
+ tray = initTray({
92
+ url,
93
+ onOpen: () => launchBrowser(url),
94
+ onCopy: () => copyToClipboard(url),
95
+ onStop: shutdown,
96
+ log: (msg) => console.error(msg),
97
+ });
67
98
  }
68
99
  async function buildSession(project) {
69
100
  const client = new OpenSpecClient(project);
@@ -124,8 +155,14 @@ function getProjects() {
124
155
  return discoverProjects(session?.project ?? "");
125
156
  }
126
157
  function openBrowser(url) {
127
- if (process.env.DATA_LOOM_NO_OPEN)
158
+ // Detached background runs have no attached console and no user waiting at the
159
+ // terminal, so never pop a browser for them (nor when explicitly suppressed).
160
+ if (process.env.DATA_LOOM_NO_OPEN || process.env.DATA_LOOM_DETACHED)
128
161
  return;
162
+ launchBrowser(url);
163
+ }
164
+ /** Open a URL in the default browser unconditionally (used by the tray). */
165
+ function launchBrowser(url) {
129
166
  try {
130
167
  if (process.platform === "win32") {
131
168
  spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
@@ -141,7 +178,130 @@ function openBrowser(url) {
141
178
  /* non-fatal — the URL is already logged */
142
179
  }
143
180
  }
144
- main().catch((err) => {
145
- console.error(err);
146
- process.exit(1);
147
- });
181
+ /** Best-effort copy of text to the system clipboard; failure is silent. */
182
+ function copyToClipboard(text) {
183
+ try {
184
+ const proc = process.platform === "win32"
185
+ ? spawn("clip")
186
+ : process.platform === "darwin"
187
+ ? spawn("pbcopy")
188
+ : spawn("xclip", ["-selection", "clipboard"]);
189
+ proc.on("error", () => { });
190
+ proc.stdin?.end(text);
191
+ }
192
+ catch {
193
+ /* no clipboard tool available — non-fatal */
194
+ }
195
+ }
196
+ // ---- CLI verb dispatch -----------------------------------------------------
197
+ // A leading reserved verb selects a lifecycle/autostart/integration command;
198
+ // anything else falls through to the foreground daemon (with argv[2] as the
199
+ // optional project path), preserving the original invocation unchanged.
200
+ const VERBS = new Set(["start", "stop", "restart", "status", "autostart", "connect", "disconnect"]);
201
+ async function runAutostart(rest) {
202
+ const sub = rest[0];
203
+ if (sub === "enable") {
204
+ await autostart.enable();
205
+ console.log("[data-loom] autostart enabled (launches on login)");
206
+ // Enabling also brings the daemon up now, so it's running this session too —
207
+ // opt out with --no-start.
208
+ if (!rest.includes("--no-start"))
209
+ await lifecycle.start();
210
+ // ...and points Claude Code at it, so the always-on path both hosts and
211
+ // registers the MCP endpoint. Best-effort: a missing `claude` CLI must not
212
+ // fail the enable (the login item + daemon already succeeded). Opt out with
213
+ // --no-connect.
214
+ if (!rest.includes("--no-connect")) {
215
+ try {
216
+ await claudeCode.connect();
217
+ }
218
+ catch (err) {
219
+ console.warn(`[data-loom] could not register with Claude Code (continuing): ${err instanceof Error ? err.message : err}`);
220
+ }
221
+ }
222
+ return;
223
+ }
224
+ if (sub === "disable") {
225
+ await autostart.disable();
226
+ console.log("[data-loom] autostart disabled");
227
+ return;
228
+ }
229
+ if (sub === "status") {
230
+ console.log((await autostart.isEnabled())
231
+ ? "[data-loom] autostart is enabled"
232
+ : "[data-loom] autostart is not enabled");
233
+ return;
234
+ }
235
+ throw new Error("usage: data-loom autostart <enable|disable|status> [--no-start] [--no-connect]");
236
+ }
237
+ async function runConnect(rest) {
238
+ const target = rest[0];
239
+ if (target === "claude-code") {
240
+ const registered = await claudeCode.connect();
241
+ if (registered) {
242
+ console.log("[data-loom] registered DataLoom in Claude Code (user scope, native HTTP)");
243
+ console.log("[data-loom] start a new Claude Code session (or /mcp reconnect) to pick it up; DataLoom must be running to serve the tools.");
244
+ }
245
+ return;
246
+ }
247
+ if (target === "claude-desktop") {
248
+ const bridge = rest.includes("--bridge");
249
+ const path = await claudeDesktop.connect({ bridge });
250
+ console.log(`[data-loom] registered DataLoom in Claude Desktop (${bridge ? "stdio bridge" : "native HTTP"}) — ${path}`);
251
+ console.log("[data-loom] restart Claude Desktop to pick it up; DataLoom must be running to serve the tools.");
252
+ return;
253
+ }
254
+ throw new Error("usage: data-loom connect <claude-code|claude-desktop [--bridge]>");
255
+ }
256
+ async function runDisconnect(rest) {
257
+ const target = rest[0];
258
+ if (target === "claude-code") {
259
+ const { removed } = await claudeCode.disconnect();
260
+ console.log(removed
261
+ ? "[data-loom] removed DataLoom from Claude Code"
262
+ : "[data-loom] DataLoom was not registered in Claude Code — nothing to remove");
263
+ return;
264
+ }
265
+ if (target === "claude-desktop") {
266
+ const { path, removed } = await claudeDesktop.disconnect();
267
+ console.log(removed
268
+ ? `[data-loom] removed DataLoom from Claude Desktop — ${path}`
269
+ : "[data-loom] DataLoom was not registered in Claude Desktop — nothing to remove");
270
+ return;
271
+ }
272
+ throw new Error("usage: data-loom disconnect <claude-code|claude-desktop>");
273
+ }
274
+ async function runCli(argv) {
275
+ const [verb, ...rest] = argv;
276
+ switch (verb) {
277
+ case "start":
278
+ return lifecycle.start(rest[0]);
279
+ case "stop":
280
+ return lifecycle.stop();
281
+ case "restart":
282
+ return lifecycle.restart(rest[0]);
283
+ case "status":
284
+ return lifecycle.status();
285
+ case "autostart":
286
+ return runAutostart(rest);
287
+ case "connect":
288
+ return runConnect(rest);
289
+ case "disconnect":
290
+ return runDisconnect(rest);
291
+ default:
292
+ throw new Error(`unknown command: ${verb}`);
293
+ }
294
+ }
295
+ const cliArgs = process.argv.slice(2);
296
+ if (cliArgs[0] && VERBS.has(cliArgs[0])) {
297
+ runCli(cliArgs).catch((err) => {
298
+ console.error(err instanceof Error ? err.message : err);
299
+ process.exit(1);
300
+ });
301
+ }
302
+ else {
303
+ main().catch((err) => {
304
+ console.error(err);
305
+ process.exit(1);
306
+ });
307
+ }
@@ -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
@@ -7,9 +7,11 @@
7
7
  // server serves every project: the target project is resolved per call from an
8
8
  // explicit `project` argument, falling back to the daemon's current dashboard
9
9
  // selection. The server holds no single project frozen for its lifetime.
10
+ import { readFileSync } from "node:fs";
10
11
  import { mkdir, readFile, writeFile } from "node:fs/promises";
11
12
  import { homedir } from "node:os";
12
- import { join, resolve } from "node:path";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
13
15
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
14
16
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
15
17
  import { OpenSpecClient } from "./openspecClient.js";
@@ -23,6 +25,19 @@ import { discoverProjects, isViewableProject } from "./projects.js";
23
25
  */
24
26
  class ToolError extends Error {
25
27
  }
28
+ // The advertised server version — read from the package manifest shipped next
29
+ // to the compiled code, so it can never drift from the released version again
30
+ // (it was hardcoded and stuck at 0.4.1 through the 0.5.0 release).
31
+ const VERSION = (() => {
32
+ try {
33
+ const here = dirname(fileURLToPath(import.meta.url));
34
+ const manifest = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
35
+ return manifest.version ?? "0.0.0";
36
+ }
37
+ catch {
38
+ return "0.0.0";
39
+ }
40
+ })();
26
41
  // Advertised to the client on connect. The "confirm before writing" gate lives
27
42
  // here, in the agent's behavior — the server cannot verify a human approved.
28
43
  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.
@@ -75,7 +90,7 @@ const PROJECT_ARG = {
75
90
  * the dashboard selection is the single fallback source of project truth.
76
91
  */
77
92
  export function createMcpServer(deps) {
78
- const server = new Server({ name: "data-loom", version: "0.4.1" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
93
+ const server = new Server({ name: "data-loom", version: VERSION }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
79
94
  // Resolve the target project for a call: explicit arg, then dashboard
80
95
  // selection, then an instructive error. Validated as a real workspace before
81
96
  // any read or write.
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
@@ -117,6 +117,16 @@ export async function startServer(opts) {
117
117
  sendError(res, 413, "request body too large");
118
118
  return;
119
119
  }
120
+ if (e instanceof InvalidJsonError) {
121
+ // A body that never parsed is the caller's encoding fault — answer with
122
+ // a JSON-RPC parse error, not a 500. The classic trigger is a Windows
123
+ // path with unescaped backslashes ("D:\projects\…" — \p is not a valid
124
+ // JSON escape), so name that in the hint. The detail only ever echoes
125
+ // the parser's complaint about the caller's own body, never host state.
126
+ if (!res.headersSent)
127
+ sendRpcParseError(res, e.message);
128
+ return;
129
+ }
120
130
  // Unexpected failures stay in the server log; the client gets nothing
121
131
  // about the host (no paths, no stack).
122
132
  console.error("[data-loom] mcp request error:", e);
@@ -174,6 +184,16 @@ export async function startServer(opts) {
174
184
  server: http,
175
185
  verifyClient: (info) => isAllowed(info.req),
176
186
  });
187
+ // ws re-emits the underlying server's 'error'; without a listener a bind
188
+ // failure (EADDRINUSE) becomes an unhandled 'error' event that crashes the
189
+ // process. The listen() promise below surfaces startup bind errors to the
190
+ // caller, so ignore those here (avoids a scary duplicate stack on the normal
191
+ // "already running" path); log only unexpected post-startup socket errors.
192
+ wss.on("error", (err) => {
193
+ if (err.code !== "EADDRINUSE") {
194
+ console.error("[data-loom] websocket error:", err);
195
+ }
196
+ });
177
197
  wss.on("connection", async (ws) => {
178
198
  const roadmap = getRoadmap();
179
199
  if (roadmap)
@@ -186,7 +206,18 @@ export async function startServer(opts) {
186
206
  /* project list optional */
187
207
  }
188
208
  });
189
- await new Promise((ready) => http.listen(port, host, ready));
209
+ // Reject (rather than crash) if the port is taken — the caller decides how to
210
+ // report an already-running instance. After a clean listen, later server
211
+ // errors are logged, not fatal.
212
+ await new Promise((resolve, reject) => {
213
+ const onError = (err) => reject(err);
214
+ http.once("error", onError);
215
+ http.listen(port, host, () => {
216
+ http.removeListener("error", onError);
217
+ http.on("error", (err) => console.error("[data-loom] http server error:", err));
218
+ resolve();
219
+ });
220
+ });
190
221
  const actualPort = http.address()?.port ?? port;
191
222
  setAllowed(actualPort);
192
223
  return {
@@ -207,9 +238,29 @@ function sendError(res, code, message) {
207
238
  res.writeHead(code, { "content-type": MIME[".json"] });
208
239
  res.end(JSON.stringify({ error: message }));
209
240
  }
241
+ /**
242
+ * Answer a request whose body was not valid JSON with a JSON-RPC parse error
243
+ * (-32700) over HTTP 400, per the Streamable-HTTP spec. `detail` is the JSON
244
+ * parser's message about the caller's own payload — safe to echo, and the hint
245
+ * names the most common cause on Windows (unescaped path backslashes).
246
+ */
247
+ function sendRpcParseError(res, detail) {
248
+ res.writeHead(400, { "content-type": MIME[".json"] });
249
+ res.end(JSON.stringify({
250
+ jsonrpc: "2.0",
251
+ error: {
252
+ code: -32700,
253
+ message: `Parse error: request body is not valid JSON (${detail}). If the payload contains Windows paths, JSON-escape the backslashes ("D:\\\\projects\\\\…") or use forward slashes.`,
254
+ },
255
+ id: null,
256
+ }));
257
+ }
210
258
  /** Raised when a request body exceeds the cap; mapped to HTTP 413. */
211
259
  class PayloadTooLargeError extends Error {
212
260
  }
261
+ /** Raised when a request body is not parseable JSON; mapped to HTTP 400 / -32700. */
262
+ class InvalidJsonError extends Error {
263
+ }
213
264
  /** Largest request body we will buffer (JSON-RPC tool calls are tiny). */
214
265
  const MAX_BODY_BYTES = 4 * 1024 * 1024;
215
266
  /** Read and JSON-parse a request body; returns undefined for an empty body. */
@@ -239,7 +290,7 @@ function readJsonBody(req) {
239
290
  resolve(JSON.parse(data));
240
291
  }
241
292
  catch (e) {
242
- reject(e instanceof Error ? e : new Error("invalid JSON body"));
293
+ reject(new InvalidJsonError(e instanceof Error ? e.message : "invalid JSON body"));
243
294
  }
244
295
  });
245
296
  req.on("error", reject);
package/dist/tray.js ADDED
@@ -0,0 +1,142 @@
1
+ // System-tray presence for the running daemon — a glanceable "is DataLoom
2
+ // running?" signal for the detached/background mode that has no console or
3
+ // window. Windows-first and dependency-free: a hidden PowerShell helper draws
4
+ // the DataLoom mark, shows a tooltip, and offers Open / Copy / Stop; menu
5
+ // clicks arrive back as stdout lines. Any failure (non-Windows, no PowerShell,
6
+ // headless/session-0, DATA_LOOM_NO_TRAY) is a silent no-op, so the tray never
7
+ // affects the daemon's ability to serve (mirror-not-launcher, DataLoom-only).
8
+ import { spawn } from "node:child_process";
9
+ const NOOP = { dispose: () => { } };
10
+ /**
11
+ * Show a tray icon for the running daemon. Returns a disposer. Never throws:
12
+ * on any unsupported/headless host or failure it returns a no-op tray so the
13
+ * caller can wire it unconditionally.
14
+ */
15
+ export function initTray(opts) {
16
+ if (process.env.DATA_LOOM_NO_TRAY)
17
+ return NOOP;
18
+ // Windows-first: elsewhere (and headless) we degrade to no tray.
19
+ if (process.platform !== "win32")
20
+ return NOOP;
21
+ try {
22
+ return startWindowsTray(opts);
23
+ }
24
+ catch (err) {
25
+ opts.log?.(`[data-loom] tray unavailable (continuing without it): ${err instanceof Error ? err.message : err}`);
26
+ return NOOP;
27
+ }
28
+ }
29
+ function startWindowsTray(opts) {
30
+ const script = buildScript(opts.url, process.pid);
31
+ // -EncodedCommand takes base64 of the UTF-16LE script, sidestepping all shell
32
+ // quoting. powershell.exe (Windows PowerShell 5.1) runs STA, which WinForms
33
+ // / NotifyIcon require.
34
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
35
+ const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
36
+ let disposed = false;
37
+ child.stdout?.setEncoding("utf8");
38
+ let buf = "";
39
+ child.stdout?.on("data", (chunk) => {
40
+ buf += chunk;
41
+ let nl;
42
+ while ((nl = buf.indexOf("\n")) >= 0) {
43
+ const line = buf.slice(0, nl).trim();
44
+ buf = buf.slice(nl + 1);
45
+ if (!line)
46
+ continue;
47
+ if (line === "open")
48
+ opts.onOpen();
49
+ else if (line === "copy")
50
+ opts.onCopy();
51
+ else if (line === "stop")
52
+ opts.onStop();
53
+ }
54
+ });
55
+ child.stderr?.setEncoding("utf8");
56
+ child.stderr?.on("data", (chunk) => {
57
+ // PowerShell serializes its progress/verbose streams to stderr as CLIXML
58
+ // when piped (e.g. "Preparing modules for first use") — benign noise. Only
59
+ // surface genuine error records.
60
+ if (!/S="error"/i.test(chunk))
61
+ return;
62
+ opts.log?.(`[data-loom] tray helper error: ${chunk.trim()}`);
63
+ });
64
+ child.on("error", (err) => opts.log?.(`[data-loom] tray helper error: ${err.message}`));
65
+ return {
66
+ dispose() {
67
+ if (disposed)
68
+ return;
69
+ disposed = true;
70
+ // Intentionally not killed here: the helper polls for its parent (this
71
+ // daemon) and, once we exit, disposes its own NotifyIcon and quits — a
72
+ // clean icon removal. A hard kill would skip that and leave a ghost icon
73
+ // in the tray until the user hovers it. dispose() is only called during
74
+ // shutdown, which exits the process immediately afterward.
75
+ try {
76
+ child.stdout?.removeAllListeners();
77
+ child.stderr?.removeAllListeners();
78
+ }
79
+ catch {
80
+ /* nothing to clean up */
81
+ }
82
+ },
83
+ };
84
+ }
85
+ /**
86
+ * The PowerShell helper. Built as joined lines (no backticks, so it survives a
87
+ * JS template context) with the URL and parent PID inlined. It draws the woven
88
+ * DataLoom mark, shows a NotifyIcon with a tooltip and a context menu, reports
89
+ * menu clicks on stdout, and self-terminates (removing its icon) as soon as the
90
+ * parent daemon is gone.
91
+ */
92
+ function buildScript(url, parentPid) {
93
+ const safeUrl = url.replace(/'/g, "''"); // single-quote escape for PS literal
94
+ return [
95
+ "$ErrorActionPreference = 'Stop'",
96
+ "$ProgressPreference = 'SilentlyContinue'",
97
+ "Add-Type -AssemblyName System.Windows.Forms",
98
+ "Add-Type -AssemblyName System.Drawing",
99
+ "$url = '" + safeUrl + "'",
100
+ "$parent = " + String(parentPid),
101
+ // Draw the woven-lattice mark (faint horizontals + accent-blue verticals).
102
+ "$bmp = New-Object System.Drawing.Bitmap 32,32",
103
+ "$g = [System.Drawing.Graphics]::FromImage($bmp)",
104
+ "$g.SmoothingMode = 'AntiAlias'",
105
+ "$g.Clear([System.Drawing.Color]::Transparent)",
106
+ "$accent = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255,37,99,184)), 2.4",
107
+ "$faint = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255,144,163,190)), 2.4",
108
+ "foreach ($y in 7,16,25) { $g.DrawLine($faint, 6, $y, 26, $y) }",
109
+ "foreach ($x in 8,16,24) { $g.DrawLine($accent, $x, 6, $x, 26) }",
110
+ "$g.Dispose()",
111
+ "$icon = [System.Drawing.Icon]::FromHandle($bmp.GetHicon())",
112
+ // Tray icon + tooltip.
113
+ "$notify = New-Object System.Windows.Forms.NotifyIcon",
114
+ "$notify.Icon = $icon",
115
+ "$notify.Text = 'DataLoom - running - ' + $url",
116
+ "$notify.Visible = $true",
117
+ // Context menu — DataLoom-only actions, reported to the daemon on stdout.
118
+ "$menu = New-Object System.Windows.Forms.ContextMenuStrip",
119
+ "$mOpen = $menu.Items.Add('Open Dashboard')",
120
+ "$mOpen.add_Click({ try { [Console]::Out.WriteLine('open'); [Console]::Out.Flush() } catch {} })",
121
+ "$mCopy = $menu.Items.Add('Copy URL')",
122
+ "$mCopy.add_Click({ try { [Console]::Out.WriteLine('copy'); [Console]::Out.Flush() } catch {} })",
123
+ "$mStop = $menu.Items.Add('Stop DataLoom')",
124
+ "$mStop.add_Click({ try { [Console]::Out.WriteLine('stop'); [Console]::Out.Flush() } catch {} })",
125
+ "$notify.ContextMenuStrip = $menu",
126
+ // Clean removal of the icon, however we exit.
127
+ "$cleanup = { try { $notify.Visible = $false } catch {}; try { $notify.Dispose() } catch {}; try { $icon.Dispose() } catch {}; try { $bmp.Dispose() } catch {} }",
128
+ // Pump WinForms messages (so the icon shows and the menu responds) while the
129
+ // parent daemon is alive; when it goes, remove our icon and quit. A manual
130
+ // DoEvents loop keeps $parent/$cleanup in the main scope, avoiding the scope
131
+ // pitfalls of .NET event-handler scriptblocks. The menu handlers above use
132
+ // only string literals, so they capture nothing.
133
+ "try {",
134
+ " while (Get-Process -Id $parent -ErrorAction SilentlyContinue) {",
135
+ " [System.Windows.Forms.Application]::DoEvents()",
136
+ " Start-Sleep -Milliseconds 150",
137
+ " }",
138
+ "} finally {",
139
+ " & $cleanup",
140
+ "}",
141
+ ].join("\n");
142
+ }
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.6.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",
@@ -34,7 +34,9 @@
34
34
  "build": "tsc",
35
35
  "start": "node dist/index.js",
36
36
  "dev": "tsc && node --watch dist/index.js",
37
- "prepublishOnly": "npm run build"
37
+ "prepublishOnly": "npm run build",
38
+ "sync-global": "node scripts/sync-global.mjs",
39
+ "postpublish": "npm run sync-global"
38
40
  },
39
41
  "publishConfig": {
40
42
  "access": "public"